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