message.go

  1package chat
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"path/filepath"
  8	"strings"
  9	"time"
 10
 11	"github.com/charmbracelet/lipgloss/v2"
 12	"github.com/charmbracelet/x/ansi"
 13	"github.com/opencode-ai/opencode/internal/diff"
 14	"github.com/opencode-ai/opencode/internal/llm/agent"
 15	"github.com/opencode-ai/opencode/internal/llm/models"
 16	"github.com/opencode-ai/opencode/internal/llm/tools"
 17	"github.com/opencode-ai/opencode/internal/message"
 18	"github.com/opencode-ai/opencode/internal/tui/styles"
 19	"github.com/opencode-ai/opencode/internal/tui/theme"
 20)
 21
 22type uiMessageType int
 23
 24const (
 25	userMessageType uiMessageType = iota
 26	assistantMessageType
 27	toolMessageType
 28
 29	maxResultHeight = 10
 30)
 31
 32type uiMessage struct {
 33	ID          string
 34	messageType uiMessageType
 35	position    int
 36	height      int
 37	content     string
 38}
 39
 40func toMarkdown(content string, focused bool, width int) string {
 41	r := styles.GetMarkdownRenderer(width)
 42	rendered, _ := r.Render(content)
 43	return rendered
 44}
 45
 46func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string {
 47	t := theme.CurrentTheme()
 48
 49	style := styles.BaseStyle().
 50		Width(width - 1).
 51		BorderLeft(true).
 52		Foreground(t.TextMuted()).
 53		BorderForeground(t.Primary()).
 54		BorderStyle(lipgloss.ThickBorder())
 55
 56	if isUser {
 57		style = style.BorderForeground(t.Secondary())
 58	}
 59
 60	// Apply markdown formatting and handle background color
 61	parts := []string{
 62		toMarkdown(msg, isFocused, width),
 63	}
 64
 65	// Remove newline at the end
 66	parts[0] = strings.TrimSuffix(parts[0], "\n")
 67	if len(info) > 0 {
 68		parts = append(parts, info...)
 69	}
 70
 71	rendered := style.Render(
 72		lipgloss.JoinVertical(
 73			lipgloss.Left,
 74			parts...,
 75		),
 76	)
 77
 78	return rendered
 79}
 80
 81func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
 82	var styledAttachments []string
 83	t := theme.CurrentTheme()
 84	attachmentStyles := styles.BaseStyle().
 85		MarginLeft(1).
 86		Background(t.TextMuted()).
 87		Foreground(t.Text())
 88	for _, attachment := range msg.BinaryContent() {
 89		file := filepath.Base(attachment.Path)
 90		var filename string
 91		if len(file) > 10 {
 92			filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
 93		} else {
 94			filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
 95		}
 96		styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
 97	}
 98	content := ""
 99	if len(styledAttachments) > 0 {
100		attachmentContent := styles.BaseStyle().Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
101		content = renderMessage(msg.Content().String(), true, isFocused, width, attachmentContent)
102	} else {
103		content = renderMessage(msg.Content().String(), true, isFocused, width)
104	}
105	userMsg := uiMessage{
106		ID:          msg.ID,
107		messageType: userMessageType,
108		position:    position,
109		height:      lipgloss.Height(content),
110		content:     content,
111	}
112	return userMsg
113}
114
115// Returns multiple uiMessages because of the tool calls
116func renderAssistantMessage(
117	msg message.Message,
118	msgIndex int,
119	allMessages []message.Message, // we need this to get tool results and the user message
120	messagesService message.Service, // We need this to get the task tool messages
121	focusedUIMessageId string,
122	isSummary bool,
123	width int,
124	position int,
125) []uiMessage {
126	messages := []uiMessage{}
127	content := msg.Content().String()
128	thinking := msg.IsThinking()
129	thinkingContent := msg.ReasoningContent().Thinking
130	finished := msg.IsFinished()
131	finishData := msg.FinishPart()
132	info := []string{}
133
134	t := theme.CurrentTheme()
135	baseStyle := styles.BaseStyle()
136
137	// Add finish info if available
138	if finished {
139		switch finishData.Reason {
140		case message.FinishReasonEndTurn:
141			took := formatTimestampDiff(msg.CreatedAt, finishData.Time)
142			info = append(info, baseStyle.
143				Width(width-1).
144				Foreground(t.TextMuted()).
145				Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took)),
146			)
147		case message.FinishReasonCanceled:
148			info = append(info, baseStyle.
149				Width(width-1).
150				Foreground(t.TextMuted()).
151				Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled")),
152			)
153		case message.FinishReasonError:
154			info = append(info, baseStyle.
155				Width(width-1).
156				Foreground(t.TextMuted()).
157				Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error")),
158			)
159		case message.FinishReasonPermissionDenied:
160			info = append(info, baseStyle.
161				Width(width-1).
162				Foreground(t.TextMuted()).
163				Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied")),
164			)
165		}
166	}
167	if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
168		if content == "" {
169			content = "*Finished without output*"
170		}
171		if isSummary {
172			info = append(info, baseStyle.Width(width-1).Foreground(t.TextMuted()).Render(" (summary)"))
173		}
174
175		content = renderMessage(content, false, true, width, info...)
176		messages = append(messages, uiMessage{
177			ID:          msg.ID,
178			messageType: assistantMessageType,
179			position:    position,
180			height:      lipgloss.Height(content),
181			content:     content,
182		})
183		position += messages[0].height
184		position++ // for the space
185	} else if thinking && thinkingContent != "" {
186		// Render the thinking content
187		content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width)
188	}
189
190	for i, toolCall := range msg.ToolCalls() {
191		toolCallContent := renderToolMessage(
192			toolCall,
193			allMessages,
194			messagesService,
195			focusedUIMessageId,
196			false,
197			width,
198			i+1,
199		)
200		messages = append(messages, toolCallContent)
201		position += toolCallContent.height
202		position++ // for the space
203	}
204	return messages
205}
206
207func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
208	for _, msg := range futureMessages {
209		for _, result := range msg.ToolResults() {
210			if result.ToolCallID == toolCallID {
211				return &result
212			}
213		}
214	}
215	return nil
216}
217
218func toolName(name string) string {
219	switch name {
220	case agent.AgentToolName:
221		return "Task"
222	case tools.BashToolName:
223		return "Bash"
224	case tools.EditToolName:
225		return "Edit"
226	case tools.FetchToolName:
227		return "Fetch"
228	case tools.GlobToolName:
229		return "Glob"
230	case tools.GrepToolName:
231		return "Grep"
232	case tools.LSToolName:
233		return "List"
234	case tools.SourcegraphToolName:
235		return "Sourcegraph"
236	case tools.ViewToolName:
237		return "View"
238	case tools.WriteToolName:
239		return "Write"
240	case tools.PatchToolName:
241		return "Patch"
242	}
243	return name
244}
245
246func getToolAction(name string) string {
247	switch name {
248	case agent.AgentToolName:
249		return "Preparing prompt..."
250	case tools.BashToolName:
251		return "Building command..."
252	case tools.EditToolName:
253		return "Preparing edit..."
254	case tools.FetchToolName:
255		return "Writing fetch..."
256	case tools.GlobToolName:
257		return "Finding files..."
258	case tools.GrepToolName:
259		return "Searching content..."
260	case tools.LSToolName:
261		return "Listing directory..."
262	case tools.SourcegraphToolName:
263		return "Searching code..."
264	case tools.ViewToolName:
265		return "Reading file..."
266	case tools.WriteToolName:
267		return "Preparing write..."
268	case tools.PatchToolName:
269		return "Preparing patch..."
270	}
271	return "Working..."
272}
273
274func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
275	params := ""
276	switch toolCall.Name {
277	case agent.AgentToolName:
278		var params agent.AgentParams
279		json.Unmarshal([]byte(toolCall.Input), &params)
280		prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
281		return renderParams(paramWidth, prompt)
282	case tools.BashToolName:
283		var params tools.BashParams
284		json.Unmarshal([]byte(toolCall.Input), &params)
285		command := strings.ReplaceAll(params.Command, "\n", " ")
286		return renderParams(paramWidth, command)
287	case tools.EditToolName:
288		var params tools.EditParams
289		json.Unmarshal([]byte(toolCall.Input), &params)
290		filePath := removeWorkingDirPrefix(params.FilePath)
291		return renderParams(paramWidth, filePath)
292	case tools.FetchToolName:
293		var params tools.FetchParams
294		json.Unmarshal([]byte(toolCall.Input), &params)
295		url := params.URL
296		toolParams := []string{
297			url,
298		}
299		if params.Format != "" {
300			toolParams = append(toolParams, "format", params.Format)
301		}
302		if params.Timeout != 0 {
303			toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
304		}
305		return renderParams(paramWidth, toolParams...)
306	case tools.GlobToolName:
307		var params tools.GlobParams
308		json.Unmarshal([]byte(toolCall.Input), &params)
309		pattern := params.Pattern
310		toolParams := []string{
311			pattern,
312		}
313		if params.Path != "" {
314			toolParams = append(toolParams, "path", params.Path)
315		}
316		return renderParams(paramWidth, toolParams...)
317	case tools.GrepToolName:
318		var params tools.GrepParams
319		json.Unmarshal([]byte(toolCall.Input), &params)
320		pattern := params.Pattern
321		toolParams := []string{
322			pattern,
323		}
324		if params.Path != "" {
325			toolParams = append(toolParams, "path", params.Path)
326		}
327		if params.Include != "" {
328			toolParams = append(toolParams, "include", params.Include)
329		}
330		if params.LiteralText {
331			toolParams = append(toolParams, "literal", "true")
332		}
333		return renderParams(paramWidth, toolParams...)
334	case tools.LSToolName:
335		var params tools.LSParams
336		json.Unmarshal([]byte(toolCall.Input), &params)
337		path := params.Path
338		if path == "" {
339			path = "."
340		}
341		return renderParams(paramWidth, path)
342	case tools.SourcegraphToolName:
343		var params tools.SourcegraphParams
344		json.Unmarshal([]byte(toolCall.Input), &params)
345		return renderParams(paramWidth, params.Query)
346	case tools.ViewToolName:
347		var params tools.ViewParams
348		json.Unmarshal([]byte(toolCall.Input), &params)
349		filePath := removeWorkingDirPrefix(params.FilePath)
350		toolParams := []string{
351			filePath,
352		}
353		if params.Limit != 0 {
354			toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
355		}
356		if params.Offset != 0 {
357			toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
358		}
359		return renderParams(paramWidth, toolParams...)
360	case tools.WriteToolName:
361		var params tools.WriteParams
362		json.Unmarshal([]byte(toolCall.Input), &params)
363		filePath := removeWorkingDirPrefix(params.FilePath)
364		return renderParams(paramWidth, filePath)
365	default:
366		input := strings.ReplaceAll(toolCall.Input, "\n", " ")
367		params = renderParams(paramWidth, input)
368	}
369	return params
370}
371
372func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
373	t := theme.CurrentTheme()
374	baseStyle := styles.BaseStyle()
375
376	if response.IsError {
377		errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
378		errContent = ansi.Truncate(errContent, width-1, "...")
379		return baseStyle.
380			Width(width).
381			Foreground(t.Error()).
382			Render(errContent)
383	}
384
385	resultContent := truncateHeight(response.Content, maxResultHeight)
386	switch toolCall.Name {
387	case agent.AgentToolName:
388		return toMarkdown(resultContent, false, width)
389	case tools.BashToolName:
390		resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
391		return toMarkdown(resultContent, true, width)
392	case tools.EditToolName:
393		metadata := tools.EditResponseMetadata{}
394		json.Unmarshal([]byte(response.Metadata), &metadata)
395		truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
396		formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width))
397		return formattedDiff
398	case tools.FetchToolName:
399		var params tools.FetchParams
400		json.Unmarshal([]byte(toolCall.Input), &params)
401		mdFormat := "markdown"
402		switch params.Format {
403		case "text":
404			mdFormat = "text"
405		case "html":
406			mdFormat = "html"
407		}
408		resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
409		return toMarkdown(resultContent, true, width)
410	case tools.GlobToolName:
411		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
412	case tools.GrepToolName:
413		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
414	case tools.LSToolName:
415		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
416	case tools.SourcegraphToolName:
417		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
418	case tools.ViewToolName:
419		metadata := tools.ViewResponseMetadata{}
420		json.Unmarshal([]byte(response.Metadata), &metadata)
421		ext := filepath.Ext(metadata.FilePath)
422		if ext == "" {
423			ext = ""
424		} else {
425			ext = strings.ToLower(ext[1:])
426		}
427		resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
428		return toMarkdown(resultContent, true, width)
429	case tools.WriteToolName:
430		params := tools.WriteParams{}
431		json.Unmarshal([]byte(toolCall.Input), &params)
432		metadata := tools.WriteResponseMetadata{}
433		json.Unmarshal([]byte(response.Metadata), &metadata)
434		ext := filepath.Ext(params.FilePath)
435		if ext == "" {
436			ext = ""
437		} else {
438			ext = strings.ToLower(ext[1:])
439		}
440		resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
441		return toMarkdown(resultContent, true, width)
442	default:
443		resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
444		return toMarkdown(resultContent, true, width)
445	}
446}
447
448func renderToolMessage(
449	toolCall message.ToolCall,
450	allMessages []message.Message,
451	messagesService message.Service,
452	focusedUIMessageId string,
453	nested bool,
454	width int,
455	position int,
456) uiMessage {
457	if nested {
458		width = width - 3
459	}
460
461	t := theme.CurrentTheme()
462	baseStyle := styles.BaseStyle()
463
464	style := baseStyle.
465		Width(width - 1).
466		BorderLeft(true).
467		BorderStyle(lipgloss.ThickBorder()).
468		PaddingLeft(1).
469		BorderForeground(t.TextMuted())
470
471	response := findToolResponse(toolCall.ID, allMessages)
472	toolNameText := baseStyle.Foreground(t.TextMuted()).
473		Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
474
475	if !toolCall.Finished {
476		// Get a brief description of what the tool is doing
477		toolAction := getToolAction(toolCall.Name)
478
479		progressText := baseStyle.
480			Width(width - 2 - lipgloss.Width(toolNameText)).
481			Foreground(t.TextMuted()).
482			Render(fmt.Sprintf("%s", toolAction))
483
484		content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
485		toolMsg := uiMessage{
486			messageType: toolMessageType,
487			position:    position,
488			height:      lipgloss.Height(content),
489			content:     content,
490		}
491		return toolMsg
492	}
493
494	params := renderToolParams(width-2-lipgloss.Width(toolNameText), toolCall)
495	responseContent := ""
496	if response != nil {
497		responseContent = renderToolResponse(toolCall, *response, width-2)
498		responseContent = strings.TrimSuffix(responseContent, "\n")
499	} else {
500		responseContent = baseStyle.
501			Italic(true).
502			Width(width - 2).
503			Foreground(t.TextMuted()).
504			Render("Waiting for response...")
505	}
506
507	parts := []string{}
508	if !nested {
509		formattedParams := baseStyle.
510			Width(width - 2 - lipgloss.Width(toolNameText)).
511			Foreground(t.TextMuted()).
512			Render(params)
513
514		parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams))
515	} else {
516		prefix := baseStyle.
517			Foreground(t.TextMuted()).
518			Render(" └ ")
519		formattedParams := baseStyle.
520			Width(width - 2 - lipgloss.Width(toolNameText)).
521			Foreground(t.TextMuted()).
522			Render(params)
523		parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams))
524	}
525
526	if toolCall.Name == agent.AgentToolName {
527		taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
528		toolCalls := []message.ToolCall{}
529		for _, v := range taskMessages {
530			toolCalls = append(toolCalls, v.ToolCalls()...)
531		}
532		for _, call := range toolCalls {
533			rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
534			parts = append(parts, rendered.content)
535		}
536	}
537	if responseContent != "" && !nested {
538		parts = append(parts, responseContent)
539	}
540
541	content := style.Render(
542		lipgloss.JoinVertical(
543			lipgloss.Left,
544			parts...,
545		),
546	)
547	if nested {
548		content = lipgloss.JoinVertical(
549			lipgloss.Left,
550			parts...,
551		)
552	}
553	toolMsg := uiMessage{
554		messageType: toolMessageType,
555		position:    position,
556		height:      lipgloss.Height(content),
557		content:     content,
558	}
559	return toolMsg
560}
561
562// Helper function to format the time difference between two Unix timestamps
563func formatTimestampDiff(start, end int64) string {
564	diffSeconds := float64(end-start) / 1000.0 // Convert to seconds
565	if diffSeconds < 1 {
566		return fmt.Sprintf("%dms", int(diffSeconds*1000))
567	}
568	if diffSeconds < 60 {
569		return fmt.Sprintf("%.1fs", diffSeconds)
570	}
571	return fmt.Sprintf("%.1fm", diffSeconds/60)
572}