message.go

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