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