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