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"
 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		styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), t.Background()),
 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 styles.ForceReplaceBackgroundWithLipgloss(
458			toMarkdown(resultContent, false, width),
459			t.Background(),
460		)
461	case tools.BashToolName:
462		resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
463		return styles.ForceReplaceBackgroundWithLipgloss(
464			toMarkdown(resultContent, true, width),
465			t.Background(),
466		)
467	case tools.EditToolName:
468		metadata := tools.EditResponseMetadata{}
469		json.Unmarshal([]byte(response.Metadata), &metadata)
470		truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
471		formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width))
472		return formattedDiff
473	case tools.FetchToolName:
474		var params tools.FetchParams
475		json.Unmarshal([]byte(toolCall.Input), &params)
476		mdFormat := "markdown"
477		switch params.Format {
478		case "text":
479			mdFormat = "text"
480		case "html":
481			mdFormat = "html"
482		}
483		resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
484		return styles.ForceReplaceBackgroundWithLipgloss(
485			toMarkdown(resultContent, true, width),
486			t.Background(),
487		)
488	case tools.GlobToolName:
489		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
490	case tools.GrepToolName:
491		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
492	case tools.LSToolName:
493		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
494	case tools.SourcegraphToolName:
495		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
496	case tools.ViewToolName:
497		metadata := tools.ViewResponseMetadata{}
498		json.Unmarshal([]byte(response.Metadata), &metadata)
499		ext := filepath.Ext(metadata.FilePath)
500		if ext == "" {
501			ext = ""
502		} else {
503			ext = strings.ToLower(ext[1:])
504		}
505		resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
506		return styles.ForceReplaceBackgroundWithLipgloss(
507			toMarkdown(resultContent, true, width),
508			t.Background(),
509		)
510	case tools.WriteToolName:
511		params := tools.WriteParams{}
512		json.Unmarshal([]byte(toolCall.Input), &params)
513		metadata := tools.WriteResponseMetadata{}
514		json.Unmarshal([]byte(response.Metadata), &metadata)
515		ext := filepath.Ext(params.FilePath)
516		if ext == "" {
517			ext = ""
518		} else {
519			ext = strings.ToLower(ext[1:])
520		}
521		resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
522		return styles.ForceReplaceBackgroundWithLipgloss(
523			toMarkdown(resultContent, true, width),
524			t.Background(),
525		)
526	default:
527		resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
528		return styles.ForceReplaceBackgroundWithLipgloss(
529			toMarkdown(resultContent, true, width),
530			t.Background(),
531		)
532	}
533}
534
535func renderToolMessage(
536	toolCall message.ToolCall,
537	allMessages []message.Message,
538	messagesService message.Service,
539	focusedUIMessageId string,
540	nested bool,
541	width int,
542	position int,
543) uiMessage {
544	if nested {
545		width = width - 3
546	}
547
548	t := theme.CurrentTheme()
549	baseStyle := styles.BaseStyle()
550
551	style := baseStyle.
552		Width(width - 1).
553		BorderLeft(true).
554		BorderStyle(lipgloss.ThickBorder()).
555		PaddingLeft(1).
556		BorderForeground(t.TextMuted())
557
558	response := findToolResponse(toolCall.ID, allMessages)
559	toolNameText := baseStyle.Foreground(t.TextMuted()).
560		Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
561
562	if !toolCall.Finished {
563		// Get a brief description of what the tool is doing
564		toolAction := getToolAction(toolCall.Name)
565
566		progressText := baseStyle.
567			Width(width - 2 - lipgloss.Width(toolNameText)).
568			Foreground(t.TextMuted()).
569			Render(fmt.Sprintf("%s", toolAction))
570
571		content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
572		toolMsg := uiMessage{
573			messageType: toolMessageType,
574			position:    position,
575			height:      lipgloss.Height(content),
576			content:     content,
577		}
578		return toolMsg
579	}
580
581	params := renderToolParams(width-2-lipgloss.Width(toolNameText), toolCall)
582	responseContent := ""
583	if response != nil {
584		responseContent = renderToolResponse(toolCall, *response, width-2)
585		responseContent = strings.TrimSuffix(responseContent, "\n")
586	} else {
587		responseContent = baseStyle.
588			Italic(true).
589			Width(width - 2).
590			Foreground(t.TextMuted()).
591			Render("Waiting for response...")
592	}
593
594	parts := []string{}
595	if !nested {
596		formattedParams := baseStyle.
597			Width(width - 2 - lipgloss.Width(toolNameText)).
598			Foreground(t.TextMuted()).
599			Render(params)
600
601		parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams))
602	} else {
603		prefix := baseStyle.
604			Foreground(t.TextMuted()).
605			Render(" └ ")
606		formattedParams := baseStyle.
607			Width(width - 2 - lipgloss.Width(toolNameText)).
608			Foreground(t.TextMuted()).
609			Render(params)
610		parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams))
611	}
612
613	if toolCall.Name == agent.AgentToolName {
614		taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
615		toolCalls := []message.ToolCall{}
616		for _, v := range taskMessages {
617			toolCalls = append(toolCalls, v.ToolCalls()...)
618		}
619		for _, call := range toolCalls {
620			rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
621			parts = append(parts, rendered.content)
622		}
623	}
624	if responseContent != "" && !nested {
625		parts = append(parts, responseContent)
626	}
627
628	content := style.Render(
629		lipgloss.JoinVertical(
630			lipgloss.Left,
631			parts...,
632		),
633	)
634	if nested {
635		content = lipgloss.JoinVertical(
636			lipgloss.Left,
637			parts...,
638		)
639	}
640	toolMsg := uiMessage{
641		messageType: toolMessageType,
642		position:    position,
643		height:      lipgloss.Height(content),
644		content:     content,
645	}
646	return toolMsg
647}
648
649// Helper function to format the time difference between two Unix timestamps
650func formatTimestampDiff(start, end int64) string {
651	diffSeconds := float64(end-start) / 1000.0 // Convert to seconds
652	if diffSeconds < 1 {
653		return fmt.Sprintf("%dms", int(diffSeconds*1000))
654	}
655	if diffSeconds < 60 {
656		return fmt.Sprintf("%.1fs", diffSeconds)
657	}
658	return fmt.Sprintf("%.1fm", diffSeconds/60)
659}