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