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 != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
155		if content == "" {
156			content = "*Finished without output*"
157		}
158
159		content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
160		messages = append(messages, uiMessage{
161			ID:          msg.ID,
162			messageType: assistantMessageType,
163			position:    position,
164			height:      lipgloss.Height(content),
165			content:     content,
166		})
167		position += messages[0].height
168		position++ // for the space
169	}
170
171	for i, toolCall := range msg.ToolCalls() {
172		toolCallContent := renderToolMessage(
173			toolCall,
174			allMessages,
175			messagesService,
176			focusedUIMessageId,
177			false,
178			width,
179			i+1,
180		)
181		messages = append(messages, toolCallContent)
182		position += toolCallContent.height
183		position++ // for the space
184	}
185	return messages
186}
187
188func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
189	for _, msg := range futureMessages {
190		for _, result := range msg.ToolResults() {
191			if result.ToolCallID == toolCallID {
192				return &result
193			}
194		}
195	}
196	return nil
197}
198
199func toolName(name string) string {
200	switch name {
201	case agent.AgentToolName:
202		return "Task"
203	case tools.BashToolName:
204		return "Bash"
205	case tools.EditToolName:
206		return "Edit"
207	case tools.FetchToolName:
208		return "Fetch"
209	case tools.GlobToolName:
210		return "Glob"
211	case tools.GrepToolName:
212		return "Grep"
213	case tools.LSToolName:
214		return "List"
215	case tools.SourcegraphToolName:
216		return "Sourcegraph"
217	case tools.ViewToolName:
218		return "View"
219	case tools.WriteToolName:
220		return "Write"
221	}
222	return name
223}
224
225// renders params, params[0] (params[1]=params[2] ....)
226func renderParams(paramsWidth int, params ...string) string {
227	if len(params) == 0 {
228		return ""
229	}
230	mainParam := params[0]
231	if len(mainParam) > paramsWidth {
232		mainParam = mainParam[:paramsWidth-3] + "..."
233	}
234
235	if len(params) == 1 {
236		return mainParam
237	}
238	otherParams := params[1:]
239	// create pairs of key/value
240	// if odd number of params, the last one is a key without value
241	if len(otherParams)%2 != 0 {
242		otherParams = append(otherParams, "")
243	}
244	parts := make([]string, 0, len(otherParams)/2)
245	for i := 0; i < len(otherParams); i += 2 {
246		key := otherParams[i]
247		value := otherParams[i+1]
248		if value == "" {
249			continue
250		}
251		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
252	}
253
254	partsRendered := strings.Join(parts, ", ")
255	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
256	if remainingWidth < 30 {
257		// No space for the params, just show the main
258		return mainParam
259	}
260
261	if len(parts) > 0 {
262		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
263	}
264
265	return ansi.Truncate(mainParam, paramsWidth, "...")
266}
267
268func removeWorkingDirPrefix(path string) string {
269	wd := config.WorkingDirectory()
270	if strings.HasPrefix(path, wd) {
271		path = strings.TrimPrefix(path, wd)
272	}
273	if strings.HasPrefix(path, "/") {
274		path = strings.TrimPrefix(path, "/")
275	}
276	if strings.HasPrefix(path, "./") {
277		path = strings.TrimPrefix(path, "./")
278	}
279	if strings.HasPrefix(path, "../") {
280		path = strings.TrimPrefix(path, "../")
281	}
282	return path
283}
284
285func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
286	params := ""
287	switch toolCall.Name {
288	case agent.AgentToolName:
289		var params agent.AgentParams
290		json.Unmarshal([]byte(toolCall.Input), &params)
291		prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
292		return renderParams(paramWidth, prompt)
293	case tools.BashToolName:
294		var params tools.BashParams
295		json.Unmarshal([]byte(toolCall.Input), &params)
296		command := strings.ReplaceAll(params.Command, "\n", " ")
297		return renderParams(paramWidth, command)
298	case tools.EditToolName:
299		var params tools.EditParams
300		json.Unmarshal([]byte(toolCall.Input), &params)
301		filePath := removeWorkingDirPrefix(params.FilePath)
302		return renderParams(paramWidth, filePath)
303	case tools.FetchToolName:
304		var params tools.FetchParams
305		json.Unmarshal([]byte(toolCall.Input), &params)
306		url := params.URL
307		toolParams := []string{
308			url,
309		}
310		if params.Format != "" {
311			toolParams = append(toolParams, "format", params.Format)
312		}
313		if params.Timeout != 0 {
314			toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
315		}
316		return renderParams(paramWidth, toolParams...)
317	case tools.GlobToolName:
318		var params tools.GlobParams
319		json.Unmarshal([]byte(toolCall.Input), &params)
320		pattern := params.Pattern
321		toolParams := []string{
322			pattern,
323		}
324		if params.Path != "" {
325			toolParams = append(toolParams, "path", params.Path)
326		}
327		return renderParams(paramWidth, toolParams...)
328	case tools.GrepToolName:
329		var params tools.GrepParams
330		json.Unmarshal([]byte(toolCall.Input), &params)
331		pattern := params.Pattern
332		toolParams := []string{
333			pattern,
334		}
335		if params.Path != "" {
336			toolParams = append(toolParams, "path", params.Path)
337		}
338		if params.Include != "" {
339			toolParams = append(toolParams, "include", params.Include)
340		}
341		if params.LiteralText {
342			toolParams = append(toolParams, "literal", "true")
343		}
344		return renderParams(paramWidth, toolParams...)
345	case tools.LSToolName:
346		var params tools.LSParams
347		json.Unmarshal([]byte(toolCall.Input), &params)
348		path := params.Path
349		if path == "" {
350			path = "."
351		}
352		return renderParams(paramWidth, path)
353	case tools.SourcegraphToolName:
354		var params tools.SourcegraphParams
355		json.Unmarshal([]byte(toolCall.Input), &params)
356		return renderParams(paramWidth, params.Query)
357	case tools.ViewToolName:
358		var params tools.ViewParams
359		json.Unmarshal([]byte(toolCall.Input), &params)
360		filePath := removeWorkingDirPrefix(params.FilePath)
361		toolParams := []string{
362			filePath,
363		}
364		if params.Limit != 0 {
365			toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
366		}
367		if params.Offset != 0 {
368			toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
369		}
370		return renderParams(paramWidth, toolParams...)
371	case tools.WriteToolName:
372		var params tools.WriteParams
373		json.Unmarshal([]byte(toolCall.Input), &params)
374		filePath := removeWorkingDirPrefix(params.FilePath)
375		return renderParams(paramWidth, filePath)
376	default:
377		input := strings.ReplaceAll(toolCall.Input, "\n", " ")
378		params = renderParams(paramWidth, input)
379	}
380	return params
381}
382
383func truncateHeight(content string, height int) string {
384	lines := strings.Split(content, "\n")
385	if len(lines) > height {
386		return strings.Join(lines[:height], "\n")
387	}
388	return content
389}
390
391func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
392	if response.IsError {
393		errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
394		errContent = ansi.Truncate(errContent, width-1, "...")
395		return styles.BaseStyle.
396			Width(width).
397			Foreground(styles.Error).
398			Render(errContent)
399	}
400	resultContent := truncateHeight(response.Content, maxResultHeight)
401	switch toolCall.Name {
402	case agent.AgentToolName:
403		return styles.ForceReplaceBackgroundWithLipgloss(
404			toMarkdown(resultContent, false, width),
405			styles.Background,
406		)
407	case tools.BashToolName:
408		resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
409		return styles.ForceReplaceBackgroundWithLipgloss(
410			toMarkdown(resultContent, true, width),
411			styles.Background,
412		)
413	case tools.EditToolName:
414		metadata := tools.EditResponseMetadata{}
415		json.Unmarshal([]byte(response.Metadata), &metadata)
416		truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
417		formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width), diff.WithStyle(diffStyle))
418		return formattedDiff
419	case tools.FetchToolName:
420		var params tools.FetchParams
421		json.Unmarshal([]byte(toolCall.Input), &params)
422		mdFormat := "markdown"
423		switch params.Format {
424		case "text":
425			mdFormat = "text"
426		case "html":
427			mdFormat = "html"
428		}
429		resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
430		return styles.ForceReplaceBackgroundWithLipgloss(
431			toMarkdown(resultContent, true, width),
432			styles.Background,
433		)
434	case tools.GlobToolName:
435		return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
436	case tools.GrepToolName:
437		return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
438	case tools.LSToolName:
439		return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
440	case tools.SourcegraphToolName:
441		return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
442	case tools.ViewToolName:
443		metadata := tools.ViewResponseMetadata{}
444		json.Unmarshal([]byte(response.Metadata), &metadata)
445		ext := filepath.Ext(metadata.FilePath)
446		if ext == "" {
447			ext = ""
448		} else {
449			ext = strings.ToLower(ext[1:])
450		}
451		resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
452		return styles.ForceReplaceBackgroundWithLipgloss(
453			toMarkdown(resultContent, true, width),
454			styles.Background,
455		)
456	case tools.WriteToolName:
457		params := tools.WriteParams{}
458		json.Unmarshal([]byte(toolCall.Input), &params)
459		metadata := tools.WriteResponseMetadata{}
460		json.Unmarshal([]byte(response.Metadata), &metadata)
461		ext := filepath.Ext(params.FilePath)
462		if ext == "" {
463			ext = ""
464		} else {
465			ext = strings.ToLower(ext[1:])
466		}
467		resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
468		return styles.ForceReplaceBackgroundWithLipgloss(
469			toMarkdown(resultContent, true, width),
470			styles.Background,
471		)
472	default:
473		resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
474		return styles.ForceReplaceBackgroundWithLipgloss(
475			toMarkdown(resultContent, true, width),
476			styles.Background,
477		)
478	}
479}
480
481func renderToolMessage(
482	toolCall message.ToolCall,
483	allMessages []message.Message,
484	messagesService message.Service,
485	focusedUIMessageId string,
486	nested bool,
487	width int,
488	position int,
489) uiMessage {
490	if nested {
491		width = width - 3
492	}
493	response := findToolResponse(toolCall.ID, allMessages)
494	toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
495	params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall)
496	responseContent := ""
497	if response != nil {
498		responseContent = renderToolResponse(toolCall, *response, width-2)
499		responseContent = strings.TrimSuffix(responseContent, "\n")
500	} else {
501		responseContent = styles.BaseStyle.
502			Italic(true).
503			Width(width - 2).
504			Foreground(styles.ForgroundDim).
505			Render("Waiting for response...")
506	}
507	style := styles.BaseStyle.
508		Width(width - 1).
509		BorderLeft(true).
510		BorderStyle(lipgloss.ThickBorder()).
511		PaddingLeft(1).
512		BorderForeground(styles.ForgroundDim)
513
514	parts := []string{}
515	if !nested {
516		params := styles.BaseStyle.
517			Width(width - 2 - lipgloss.Width(toolName)).
518			Foreground(styles.ForgroundDim).
519			Render(params)
520
521		parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolName, params))
522	} else {
523		prefix := styles.BaseStyle.
524			Foreground(styles.ForgroundDim).
525			Render(" └ ")
526		params := styles.BaseStyle.
527			Width(width - 2 - lipgloss.Width(toolName)).
528			Foreground(styles.ForgroundMid).
529			Render(params)
530		parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolName, params))
531	}
532	if toolCall.Name == agent.AgentToolName {
533		taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
534		toolCalls := []message.ToolCall{}
535		for _, v := range taskMessages {
536			toolCalls = append(toolCalls, v.ToolCalls()...)
537		}
538		for _, call := range toolCalls {
539			rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
540			parts = append(parts, rendered.content)
541		}
542	}
543	if responseContent != "" && !nested {
544		parts = append(parts, responseContent)
545	}
546
547	content := style.Render(
548		lipgloss.JoinVertical(
549			lipgloss.Left,
550			parts...,
551		),
552	)
553	if nested {
554		content = lipgloss.JoinVertical(
555			lipgloss.Left,
556			parts...,
557		)
558	}
559	toolMsg := uiMessage{
560		messageType: toolMessageType,
561		position:    position,
562		height:      lipgloss.Height(content),
563		content:     content,
564	}
565	return toolMsg
566}