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
275func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
276	params := ""
277	switch toolCall.Name {
278	case agent.AgentToolName:
279		var params agent.AgentParams
280		json.Unmarshal([]byte(toolCall.Input), &params)
281		prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
282		return renderParams(paramWidth, prompt)
283	case tools.BashToolName:
284		var params tools.BashParams
285		json.Unmarshal([]byte(toolCall.Input), &params)
286		command := strings.ReplaceAll(params.Command, "\n", " ")
287		return renderParams(paramWidth, command)
288	case tools.EditToolName:
289		var params tools.EditParams
290		json.Unmarshal([]byte(toolCall.Input), &params)
291		filePath := removeWorkingDirPrefix(params.FilePath)
292		return renderParams(paramWidth, filePath)
293	case tools.FetchToolName:
294		var params tools.FetchParams
295		json.Unmarshal([]byte(toolCall.Input), &params)
296		url := params.URL
297		toolParams := []string{
298			url,
299		}
300		if params.Format != "" {
301			toolParams = append(toolParams, "format", params.Format)
302		}
303		if params.Timeout != 0 {
304			toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
305		}
306		return renderParams(paramWidth, toolParams...)
307	case tools.GlobToolName:
308		var params tools.GlobParams
309		json.Unmarshal([]byte(toolCall.Input), &params)
310		pattern := params.Pattern
311		toolParams := []string{
312			pattern,
313		}
314		if params.Path != "" {
315			toolParams = append(toolParams, "path", params.Path)
316		}
317		return renderParams(paramWidth, toolParams...)
318	case tools.GrepToolName:
319		var params tools.GrepParams
320		json.Unmarshal([]byte(toolCall.Input), &params)
321		pattern := params.Pattern
322		toolParams := []string{
323			pattern,
324		}
325		if params.Path != "" {
326			toolParams = append(toolParams, "path", params.Path)
327		}
328		if params.Include != "" {
329			toolParams = append(toolParams, "include", params.Include)
330		}
331		if params.LiteralText {
332			toolParams = append(toolParams, "literal", "true")
333		}
334		return renderParams(paramWidth, toolParams...)
335	case tools.LSToolName:
336		var params tools.LSParams
337		json.Unmarshal([]byte(toolCall.Input), &params)
338		path := params.Path
339		if path == "" {
340			path = "."
341		}
342		return renderParams(paramWidth, path)
343	case tools.SourcegraphToolName:
344		var params tools.SourcegraphParams
345		json.Unmarshal([]byte(toolCall.Input), &params)
346		return renderParams(paramWidth, params.Query)
347	case tools.ViewToolName:
348		var params tools.ViewParams
349		json.Unmarshal([]byte(toolCall.Input), &params)
350		filePath := removeWorkingDirPrefix(params.FilePath)
351		toolParams := []string{
352			filePath,
353		}
354		if params.Limit != 0 {
355			toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
356		}
357		if params.Offset != 0 {
358			toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
359		}
360		return renderParams(paramWidth, toolParams...)
361	case tools.WriteToolName:
362		var params tools.WriteParams
363		json.Unmarshal([]byte(toolCall.Input), &params)
364		filePath := removeWorkingDirPrefix(params.FilePath)
365		return renderParams(paramWidth, filePath)
366	default:
367		input := strings.ReplaceAll(toolCall.Input, "\n", " ")
368		params = renderParams(paramWidth, input)
369	}
370	return params
371}
372
373func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
374	t := theme.CurrentTheme()
375	baseStyle := styles.BaseStyle()
376
377	if response.IsError {
378		errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
379		errContent = ansi.Truncate(errContent, width-1, "...")
380		return baseStyle.
381			Width(width).
382			Foreground(t.Error()).
383			Render(errContent)
384	}
385
386	resultContent := truncateHeight(response.Content, maxResultHeight)
387	switch toolCall.Name {
388	case agent.AgentToolName:
389		return toMarkdown(resultContent, false, width)
390	case tools.BashToolName:
391		resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
392		return toMarkdown(resultContent, true, width)
393	case tools.EditToolName:
394		metadata := tools.EditResponseMetadata{}
395		json.Unmarshal([]byte(response.Metadata), &metadata)
396		truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
397		formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width))
398		return formattedDiff
399	case tools.FetchToolName:
400		var params tools.FetchParams
401		json.Unmarshal([]byte(toolCall.Input), &params)
402		mdFormat := "markdown"
403		switch params.Format {
404		case "text":
405			mdFormat = "text"
406		case "html":
407			mdFormat = "html"
408		}
409		resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
410		return toMarkdown(resultContent, true, width)
411	case tools.GlobToolName:
412		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
413	case tools.GrepToolName:
414		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
415	case tools.LSToolName:
416		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
417	case tools.SourcegraphToolName:
418		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
419	case tools.ViewToolName:
420		metadata := tools.ViewResponseMetadata{}
421		json.Unmarshal([]byte(response.Metadata), &metadata)
422		ext := filepath.Ext(metadata.FilePath)
423		if ext == "" {
424			ext = ""
425		} else {
426			ext = strings.ToLower(ext[1:])
427		}
428		resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
429		return toMarkdown(resultContent, true, width)
430	case tools.WriteToolName:
431		params := tools.WriteParams{}
432		json.Unmarshal([]byte(toolCall.Input), &params)
433		metadata := tools.WriteResponseMetadata{}
434		json.Unmarshal([]byte(response.Metadata), &metadata)
435		ext := filepath.Ext(params.FilePath)
436		if ext == "" {
437			ext = ""
438		} else {
439			ext = strings.ToLower(ext[1:])
440		}
441		resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
442		return toMarkdown(resultContent, true, width)
443	default:
444		resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
445		return toMarkdown(resultContent, true, width)
446	}
447}
448
449func renderToolMessage(
450	toolCall message.ToolCall,
451	allMessages []message.Message,
452	messagesService message.Service,
453	focusedUIMessageId string,
454	nested bool,
455	width int,
456	position int,
457) uiMessage {
458	if nested {
459		width = width - 3
460	}
461
462	t := theme.CurrentTheme()
463	baseStyle := styles.BaseStyle()
464
465	style := baseStyle.
466		Width(width - 1).
467		BorderLeft(true).
468		BorderStyle(lipgloss.ThickBorder()).
469		PaddingLeft(1).
470		BorderForeground(t.TextMuted())
471
472	response := findToolResponse(toolCall.ID, allMessages)
473	toolNameText := baseStyle.Foreground(t.TextMuted()).
474		Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
475
476	if !toolCall.Finished {
477		// Get a brief description of what the tool is doing
478		toolAction := getToolAction(toolCall.Name)
479
480		progressText := baseStyle.
481			Width(width - 2 - lipgloss.Width(toolNameText)).
482			Foreground(t.TextMuted()).
483			Render(fmt.Sprintf("%s", toolAction))
484
485		content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
486		toolMsg := uiMessage{
487			messageType: toolMessageType,
488			position:    position,
489			height:      lipgloss.Height(content),
490			content:     content,
491		}
492		return toolMsg
493	}
494
495	params := renderToolParams(width-2-lipgloss.Width(toolNameText), toolCall)
496	responseContent := ""
497	if response != nil {
498		responseContent = renderToolResponse(toolCall, *response, width-2)
499		responseContent = strings.TrimSuffix(responseContent, "\n")
500	} else {
501		responseContent = baseStyle.
502			Italic(true).
503			Width(width - 2).
504			Foreground(t.TextMuted()).
505			Render("Waiting for response...")
506	}
507
508	parts := []string{}
509	if !nested {
510		formattedParams := baseStyle.
511			Width(width - 2 - lipgloss.Width(toolNameText)).
512			Foreground(t.TextMuted()).
513			Render(params)
514
515		parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams))
516	} else {
517		prefix := baseStyle.
518			Foreground(t.TextMuted()).
519			Render(" └ ")
520		formattedParams := baseStyle.
521			Width(width - 2 - lipgloss.Width(toolNameText)).
522			Foreground(t.TextMuted()).
523			Render(params)
524		parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams))
525	}
526
527	if toolCall.Name == agent.AgentToolName {
528		taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
529		toolCalls := []message.ToolCall{}
530		for _, v := range taskMessages {
531			toolCalls = append(toolCalls, v.ToolCalls()...)
532		}
533		for _, call := range toolCalls {
534			rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
535			parts = append(parts, rendered.content)
536		}
537	}
538	if responseContent != "" && !nested {
539		parts = append(parts, responseContent)
540	}
541
542	content := style.Render(
543		lipgloss.JoinVertical(
544			lipgloss.Left,
545			parts...,
546		),
547	)
548	if nested {
549		content = lipgloss.JoinVertical(
550			lipgloss.Left,
551			parts...,
552		)
553	}
554	toolMsg := uiMessage{
555		messageType: toolMessageType,
556		position:    position,
557		height:      lipgloss.Height(content),
558		content:     content,
559	}
560	return toolMsg
561}
562
563func removeWorkingDirPrefix(path string) string {
564	wd := config.WorkingDirectory()
565	path = strings.TrimPrefix(path, wd)
566	return path
567}
568
569func truncateHeight(content string, height int) string {
570	lines := strings.Split(content, "\n")
571	if len(lines) > height {
572		return strings.Join(lines[:height], "\n")
573	}
574	return content
575}
576
577func renderParams(paramsWidth int, params ...string) string {
578	if len(params) == 0 {
579		return ""
580	}
581	mainParam := params[0]
582	if len(mainParam) > paramsWidth {
583		mainParam = mainParam[:paramsWidth-3] + "..."
584	}
585
586	if len(params) == 1 {
587		return mainParam
588	}
589	otherParams := params[1:]
590	// create pairs of key/value
591	// if odd number of params, the last one is a key without value
592	if len(otherParams)%2 != 0 {
593		otherParams = append(otherParams, "")
594	}
595	parts := make([]string, 0, len(otherParams)/2)
596	for i := 0; i < len(otherParams); i += 2 {
597		key := otherParams[i]
598		value := otherParams[i+1]
599		if value == "" {
600			continue
601		}
602		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
603	}
604
605	partsRendered := strings.Join(parts, ", ")
606	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
607	if remainingWidth < 30 {
608		// No space for the params, just show the main
609		return mainParam
610	}
611
612	if len(parts) > 0 {
613		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
614	}
615
616	return ansi.Truncate(mainParam, paramsWidth, "...")
617}
618
619// Helper function to format the time difference between two Unix timestamps
620func formatTimestampDiff(start, end int64) string {
621	diffSeconds := float64(end-start) / 1000.0 // Convert to seconds
622	if diffSeconds < 1 {
623		return fmt.Sprintf("%dms", int(diffSeconds*1000))
624	}
625	if diffSeconds < 60 {
626		return fmt.Sprintf("%.1fs", diffSeconds)
627	}
628	return fmt.Sprintf("%.1fm", diffSeconds/60)
629}