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