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