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