1package chat
2
3import (
4 "fmt"
5 "strings"
6
7 tea "charm.land/bubbletea/v2"
8 "charm.land/lipgloss/v2"
9 "charm.land/lipgloss/v2/tree"
10 "github.com/charmbracelet/crush/internal/agent"
11 "github.com/charmbracelet/crush/internal/agent/tools"
12 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/ui/anim"
14 "github.com/charmbracelet/crush/internal/ui/common"
15 "github.com/charmbracelet/crush/internal/ui/styles"
16 "github.com/charmbracelet/x/ansi"
17)
18
19// responseContextHeight limits the number of lines displayed in tool output.
20const responseContextHeight = 10
21
22// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
23const toolBodyLeftPaddingTotal = 2
24
25// ToolStatus represents the current state of a tool call.
26type ToolStatus int
27
28const (
29 ToolStatusAwaitingPermission ToolStatus = iota
30 ToolStatusRunning
31 ToolStatusSuccess
32 ToolStatusError
33 ToolStatusCanceled
34)
35
36// ToolMessageItem represents a tool call message in the chat UI.
37type ToolMessageItem interface {
38 MessageItem
39
40 ToolCall() message.ToolCall
41 SetToolCall(tc message.ToolCall)
42 SetResult(res *message.ToolResult)
43 MessageID() string
44 SetMessageID(id string)
45}
46
47// Compactable is an interface for tool items that can render in a compacted mode.
48// When compact mode is enabled, tools render as a compact single-line header.
49type Compactable interface {
50 SetCompact(compact bool)
51}
52
53// SpinningState contains the state passed to SpinningFunc for custom spinning logic.
54type SpinningState struct {
55 ToolCall message.ToolCall
56 Result *message.ToolResult
57 Canceled bool
58}
59
60// SpinningFunc is a function type for custom spinning logic.
61// Returns true if the tool should show the spinning animation.
62type SpinningFunc func(state SpinningState) bool
63
64// DefaultToolRenderContext implements the default [ToolRenderer] interface.
65type DefaultToolRenderContext struct{}
66
67// RenderTool implements the [ToolRenderer] interface.
68func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
69 return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
70}
71
72// ToolRenderOpts contains the data needed to render a tool call.
73type ToolRenderOpts struct {
74 ToolCall message.ToolCall
75 Result *message.ToolResult
76 Canceled bool
77 Anim *anim.Anim
78 ExpandedContent bool
79 Compact bool
80 IsSpinning bool
81 PermissionRequested bool
82 PermissionGranted bool
83}
84
85// Status returns the current status of the tool call.
86func (opts *ToolRenderOpts) Status() ToolStatus {
87 if opts.Canceled && opts.Result == nil {
88 return ToolStatusCanceled
89 }
90 if opts.Result != nil {
91 if opts.Result.IsError {
92 return ToolStatusError
93 }
94 return ToolStatusSuccess
95 }
96 if opts.PermissionRequested && !opts.PermissionGranted {
97 return ToolStatusAwaitingPermission
98 }
99 return ToolStatusRunning
100}
101
102// ToolRenderer represents an interface for rendering tool calls.
103type ToolRenderer interface {
104 RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
105}
106
107// ToolRendererFunc is a function type that implements the [ToolRenderer] interface.
108type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string
109
110// RenderTool implements the ToolRenderer interface.
111func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
112 return f(sty, width, opts)
113}
114
115// baseToolMessageItem represents a tool call message that can be displayed in the UI.
116type baseToolMessageItem struct {
117 *highlightableMessageItem
118 *cachedMessageItem
119 *focusableMessageItem
120
121 toolRenderer ToolRenderer
122 toolCall message.ToolCall
123 result *message.ToolResult
124 messageID string
125 canceled bool
126 permissionRequested bool
127 permissionGranted bool
128 // we use this so we can efficiently cache
129 // tools that have a capped width (e.x bash.. and others)
130 hasCappedWidth bool
131 // isCompact indicates this tool should render in compact mode.
132 isCompact bool
133 // spinningFunc allows tools to override the default spinning logic.
134 // If nil, uses the default: !toolCall.Finished && !canceled.
135 spinningFunc SpinningFunc
136
137 sty *styles.Styles
138 anim *anim.Anim
139 expandedContent bool
140}
141
142// newBaseToolMessageItem is the internal constructor for base tool message items.
143func newBaseToolMessageItem(
144 sty *styles.Styles,
145 toolCall message.ToolCall,
146 result *message.ToolResult,
147 toolRenderer ToolRenderer,
148 canceled bool,
149) *baseToolMessageItem {
150 // we only do full width for diffs (as far as I know)
151 hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
152
153 t := &baseToolMessageItem{
154 highlightableMessageItem: defaultHighlighter(sty),
155 cachedMessageItem: &cachedMessageItem{},
156 focusableMessageItem: &focusableMessageItem{},
157 sty: sty,
158 toolRenderer: toolRenderer,
159 toolCall: toolCall,
160 result: result,
161 canceled: canceled,
162 hasCappedWidth: hasCappedWidth,
163 }
164 t.anim = anim.New(anim.Settings{
165 ID: toolCall.ID,
166 Size: 15,
167 GradColorA: sty.Primary,
168 GradColorB: sty.Secondary,
169 LabelColor: sty.FgBase,
170 CycleColors: true,
171 })
172
173 return t
174}
175
176// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
177//
178// It returns a specific tool message item type if implemented, otherwise it
179// returns a generic tool message item. The messageID is the ID of the assistant
180// message containing this tool call.
181func NewToolMessageItem(
182 sty *styles.Styles,
183 messageID string,
184 toolCall message.ToolCall,
185 result *message.ToolResult,
186 canceled bool,
187) ToolMessageItem {
188 var item ToolMessageItem
189 switch toolCall.Name {
190 case tools.BashToolName:
191 item = NewBashToolMessageItem(sty, toolCall, result, canceled)
192 case tools.JobOutputToolName:
193 item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
194 case tools.JobKillToolName:
195 item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
196 case tools.ViewToolName:
197 item = NewViewToolMessageItem(sty, toolCall, result, canceled)
198 case tools.WriteToolName:
199 item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
200 case tools.EditToolName:
201 item = NewEditToolMessageItem(sty, toolCall, result, canceled)
202 case tools.MultiEditToolName:
203 item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
204 case tools.GlobToolName:
205 item = NewGlobToolMessageItem(sty, toolCall, result, canceled)
206 case tools.GrepToolName:
207 item = NewGrepToolMessageItem(sty, toolCall, result, canceled)
208 case tools.LSToolName:
209 item = NewLSToolMessageItem(sty, toolCall, result, canceled)
210 case tools.DownloadToolName:
211 item = NewDownloadToolMessageItem(sty, toolCall, result, canceled)
212 case tools.FetchToolName:
213 item = NewFetchToolMessageItem(sty, toolCall, result, canceled)
214 case tools.SourcegraphToolName:
215 item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
216 case tools.DiagnosticsToolName:
217 item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled)
218 case agent.AgentToolName:
219 item = NewAgentToolMessageItem(sty, toolCall, result, canceled)
220 case tools.AgenticFetchToolName:
221 item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled)
222 case tools.WebFetchToolName:
223 item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled)
224 case tools.WebSearchToolName:
225 item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled)
226 case tools.TodosToolName:
227 item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
228 default:
229 // TODO: Implement other tool items
230 item = newBaseToolMessageItem(
231 sty,
232 toolCall,
233 result,
234 &DefaultToolRenderContext{},
235 canceled,
236 )
237 }
238 item.SetMessageID(messageID)
239 return item
240}
241
242// SetCompact implements the Compactable interface.
243func (t *baseToolMessageItem) SetCompact(compact bool) {
244 t.isCompact = compact
245 t.clearCache()
246}
247
248// ID returns the unique identifier for this tool message item.
249func (t *baseToolMessageItem) ID() string {
250 return t.toolCall.ID
251}
252
253// StartAnimation starts the assistant message animation if it should be spinning.
254func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
255 if !t.isSpinning() {
256 return nil
257 }
258 return t.anim.Start()
259}
260
261// Animate progresses the assistant message animation if it should be spinning.
262func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
263 if !t.isSpinning() {
264 return nil
265 }
266 return t.anim.Animate(msg)
267}
268
269// Render renders the tool message item at the given width.
270func (t *baseToolMessageItem) Render(width int) string {
271 toolItemWidth := width - messageLeftPaddingTotal
272 if t.hasCappedWidth {
273 toolItemWidth = cappedMessageWidth(width)
274 }
275 style := t.sty.Chat.Message.ToolCallBlurred
276 if t.focused {
277 style = t.sty.Chat.Message.ToolCallFocused
278 }
279
280 if t.isCompact {
281 style = t.sty.Chat.Message.ToolCallCompact
282 }
283
284 content, height, ok := t.getCachedRender(toolItemWidth)
285 // if we are spinning or there is no cache rerender
286 if !ok || t.isSpinning() {
287 content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
288 ToolCall: t.toolCall,
289 Result: t.result,
290 Canceled: t.canceled,
291 Anim: t.anim,
292 ExpandedContent: t.expandedContent,
293 Compact: t.isCompact,
294 PermissionRequested: t.permissionRequested,
295 PermissionGranted: t.permissionGranted,
296 IsSpinning: t.isSpinning(),
297 })
298 height = lipgloss.Height(content)
299 // cache the rendered content
300 t.setCachedRender(content, toolItemWidth, height)
301 }
302
303 highlightedContent := t.renderHighlighted(content, toolItemWidth, height)
304 return style.Render(highlightedContent)
305}
306
307// ToolCall returns the tool call associated with this message item.
308func (t *baseToolMessageItem) ToolCall() message.ToolCall {
309 return t.toolCall
310}
311
312// SetToolCall sets the tool call associated with this message item.
313func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
314 t.toolCall = tc
315 t.clearCache()
316}
317
318// SetResult sets the tool result associated with this message item.
319func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
320 t.result = res
321 t.clearCache()
322}
323
324// MessageID returns the ID of the message containing this tool call.
325func (t *baseToolMessageItem) MessageID() string {
326 return t.messageID
327}
328
329// SetMessageID sets the ID of the message containing this tool call.
330func (t *baseToolMessageItem) SetMessageID(id string) {
331 t.messageID = id
332}
333
334// SetPermissionRequested sets whether permission has been requested for this tool call.
335// TODO: Consider merging with SetPermissionGranted and add an interface for
336// permission management.
337func (t *baseToolMessageItem) SetPermissionRequested(requested bool) {
338 t.permissionRequested = requested
339 t.clearCache()
340}
341
342// SetPermissionGranted sets whether permission has been granted for this tool call.
343// TODO: Consider merging with SetPermissionRequested and add an interface for
344// permission management.
345func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
346 t.permissionGranted = granted
347 t.clearCache()
348}
349
350// isSpinning returns true if the tool should show animation.
351func (t *baseToolMessageItem) isSpinning() bool {
352 if t.spinningFunc != nil {
353 return t.spinningFunc(SpinningState{
354 ToolCall: t.toolCall,
355 Result: t.result,
356 Canceled: t.canceled,
357 })
358 }
359 return !t.toolCall.Finished && !t.canceled
360}
361
362// SetSpinningFunc sets a custom function to determine if the tool should spin.
363func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
364 t.spinningFunc = fn
365}
366
367// ToggleExpanded toggles the expanded state of the thinking box.
368func (t *baseToolMessageItem) ToggleExpanded() {
369 t.expandedContent = !t.expandedContent
370 t.clearCache()
371}
372
373// HandleMouseClick implements MouseClickable.
374func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
375 if btn != ansi.MouseLeft {
376 return false
377 }
378 t.ToggleExpanded()
379 return true
380}
381
382// pendingTool renders a tool that is still in progress with an animation.
383func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
384 icon := sty.Tool.IconPending.Render()
385 toolName := sty.Tool.NameNormal.Render(name)
386
387 var animView string
388 if anim != nil {
389 animView = anim.Render()
390 }
391
392 return fmt.Sprintf("%s %s %s", icon, toolName, animView)
393}
394
395// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
396// Returns the rendered output and true if early state was handled.
397func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
398 var msg string
399 switch opts.Status() {
400 case ToolStatusError:
401 msg = toolErrorContent(sty, opts.Result, width)
402 case ToolStatusCanceled:
403 msg = sty.Tool.StateCancelled.Render("Canceled.")
404 case ToolStatusAwaitingPermission:
405 msg = sty.Tool.StateWaiting.Render("Requesting permission...")
406 case ToolStatusRunning:
407 msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
408 default:
409 return "", false
410 }
411 return msg, true
412}
413
414// toolErrorContent formats an error message with ERROR tag.
415func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
416 if result == nil {
417 return ""
418 }
419 errContent := strings.ReplaceAll(result.Content, "\n", " ")
420 errTag := sty.Tool.ErrorTag.Render("ERROR")
421 tagWidth := lipgloss.Width(errTag)
422 errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
423 return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
424}
425
426// toolIcon returns the status icon for a tool call.
427// toolIcon returns the status icon for a tool call based on its status.
428func toolIcon(sty *styles.Styles, status ToolStatus) string {
429 switch status {
430 case ToolStatusSuccess:
431 return sty.Tool.IconSuccess.String()
432 case ToolStatusError:
433 return sty.Tool.IconError.String()
434 case ToolStatusCanceled:
435 return sty.Tool.IconCancelled.String()
436 default:
437 return sty.Tool.IconPending.String()
438 }
439}
440
441// toolParamList formats parameters as "main (key=value, ...)" with truncation.
442// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
443func toolParamList(sty *styles.Styles, params []string, width int) string {
444 // minSpaceForMainParam is the min space required for the main param
445 // if this is less that the value set we will only show the main param nothing else
446 const minSpaceForMainParam = 30
447 if len(params) == 0 {
448 return ""
449 }
450
451 mainParam := params[0]
452
453 // Build key=value pairs from remaining params (consecutive key, value pairs).
454 var kvPairs []string
455 for i := 1; i+1 < len(params); i += 2 {
456 if params[i+1] != "" {
457 kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
458 }
459 }
460
461 // Try to include key=value pairs if there's enough space.
462 output := mainParam
463 if len(kvPairs) > 0 {
464 partsStr := strings.Join(kvPairs, ", ")
465 if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
466 output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
467 }
468 }
469
470 if width >= 0 {
471 output = ansi.Truncate(output, width, "…")
472 }
473 return sty.Tool.ParamMain.Render(output)
474}
475
476// toolHeader builds the tool header line: "● ToolName params..."
477func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string {
478 icon := toolIcon(sty, status)
479 nameStyle := sty.Tool.NameNormal
480 if nested {
481 nameStyle = sty.Tool.NameNested
482 }
483 toolName := nameStyle.Render(name)
484 prefix := fmt.Sprintf("%s %s ", icon, toolName)
485 prefixWidth := lipgloss.Width(prefix)
486 remainingWidth := width - prefixWidth
487 paramsStr := toolParamList(sty, params, remainingWidth)
488 return prefix + paramsStr
489}
490
491// toolOutputPlainContent renders plain text with optional expansion support.
492func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
493 content = strings.ReplaceAll(content, "\r\n", "\n")
494 content = strings.ReplaceAll(content, "\t", " ")
495 content = strings.TrimSpace(content)
496 lines := strings.Split(content, "\n")
497
498 maxLines := responseContextHeight
499 if expanded {
500 maxLines = len(lines) // Show all
501 }
502
503 var out []string
504 for i, ln := range lines {
505 if i >= maxLines {
506 break
507 }
508 ln = " " + ln
509 if lipgloss.Width(ln) > width {
510 ln = ansi.Truncate(ln, width, "…")
511 }
512 out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
513 }
514
515 wasTruncated := len(lines) > responseContextHeight
516
517 if !expanded && wasTruncated {
518 out = append(out, sty.Tool.ContentTruncation.
519 Width(width).
520 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight)))
521 }
522
523 return strings.Join(out, "\n")
524}
525
526// toolOutputCodeContent renders code with syntax highlighting and line numbers.
527func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
528 content = strings.ReplaceAll(content, "\r\n", "\n")
529 content = strings.ReplaceAll(content, "\t", " ")
530
531 lines := strings.Split(content, "\n")
532 maxLines := responseContextHeight
533 if expanded {
534 maxLines = len(lines)
535 }
536
537 // Truncate if needed.
538 displayLines := lines
539 if len(lines) > maxLines {
540 displayLines = lines[:maxLines]
541 }
542
543 bg := sty.Tool.ContentCodeBg
544 highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg)
545 highlightedLines := strings.Split(highlighted, "\n")
546
547 // Calculate line number width.
548 maxLineNumber := len(displayLines) + offset
549 maxDigits := getDigits(maxLineNumber)
550 numFmt := fmt.Sprintf("%%%dd", maxDigits)
551
552 bodyWidth := width - toolBodyLeftPaddingTotal
553 codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding
554
555 var out []string
556 for i, ln := range highlightedLines {
557 lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
558
559 if lipgloss.Width(ln) > codeWidth {
560 ln = ansi.Truncate(ln, codeWidth, "…")
561 }
562
563 codeLine := sty.Tool.ContentCodeLine.
564 Width(codeWidth).
565 PaddingLeft(2).
566 Render(ln)
567
568 out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
569 }
570
571 // Add truncation message if needed.
572 if len(lines) > maxLines && !expanded {
573 out = append(out, sty.Tool.ContentCodeTruncation.
574 Width(bodyWidth).
575 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
576 )
577 }
578
579 return sty.Tool.Body.Render(strings.Join(out, "\n"))
580}
581
582// toolOutputImageContent renders image data with size info.
583func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
584 dataSize := len(data) * 3 / 4
585 sizeStr := formatSize(dataSize)
586
587 loaded := sty.Base.Foreground(sty.Green).Render("Loaded")
588 arrow := sty.Base.Foreground(sty.GreenDark).Render("→")
589 typeStyled := sty.Base.Render(mediaType)
590 sizeStyled := sty.Subtle.Render(sizeStr)
591
592 return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled))
593}
594
595// getDigits returns the number of digits in a number.
596func getDigits(n int) int {
597 if n == 0 {
598 return 1
599 }
600 if n < 0 {
601 n = -n
602 }
603 digits := 0
604 for n > 0 {
605 n /= 10
606 digits++
607 }
608 return digits
609}
610
611// formatSize formats byte size into human readable format.
612func formatSize(bytes int) string {
613 const (
614 kb = 1024
615 mb = kb * 1024
616 )
617 switch {
618 case bytes >= mb:
619 return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
620 case bytes >= kb:
621 return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
622 default:
623 return fmt.Sprintf("%d B", bytes)
624 }
625}
626
627// toolOutputDiffContent renders a diff between old and new content.
628func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
629 bodyWidth := width - toolBodyLeftPaddingTotal
630
631 formatter := common.DiffFormatter(sty).
632 Before(file, oldContent).
633 After(file, newContent).
634 Width(bodyWidth)
635
636 // Use split view for wide terminals.
637 if width > maxTextWidth {
638 formatter = formatter.Split()
639 }
640
641 formatted := formatter.String()
642 lines := strings.Split(formatted, "\n")
643
644 // Truncate if needed.
645 maxLines := responseContextHeight
646 if expanded {
647 maxLines = len(lines)
648 }
649
650 if len(lines) > maxLines && !expanded {
651 truncMsg := sty.Tool.DiffTruncation.
652 Width(bodyWidth).
653 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
654 formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
655 }
656
657 return sty.Tool.Body.Render(formatted)
658}
659
660// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
661// Returns empty string if timeout is 0.
662func formatTimeout(timeout int) string {
663 if timeout == 0 {
664 return ""
665 }
666 return fmt.Sprintf("%ds", timeout)
667}
668
669// formatNonZero returns string representation of non-zero integers, empty string for zero.
670func formatNonZero(value int) string {
671 if value == 0 {
672 return ""
673 }
674 return fmt.Sprintf("%d", value)
675}
676
677// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
678func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
679 bodyWidth := width - toolBodyLeftPaddingTotal
680
681 formatter := common.DiffFormatter(sty).
682 Before(file, meta.OldContent).
683 After(file, meta.NewContent).
684 Width(bodyWidth)
685
686 // Use split view for wide terminals.
687 if width > maxTextWidth {
688 formatter = formatter.Split()
689 }
690
691 formatted := formatter.String()
692 lines := strings.Split(formatted, "\n")
693
694 // Truncate if needed.
695 maxLines := responseContextHeight
696 if expanded {
697 maxLines = len(lines)
698 }
699
700 if len(lines) > maxLines && !expanded {
701 truncMsg := sty.Tool.DiffTruncation.
702 Width(bodyWidth).
703 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
704 formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
705 }
706
707 // Add failed edits note if any exist.
708 if len(meta.EditsFailed) > 0 {
709 noteTag := sty.Tool.NoteTag.Render("Note")
710 noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
711 note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
712 formatted = formatted + "\n\n" + note
713 }
714
715 return sty.Tool.Body.Render(formatted)
716}
717
718// roundedEnumerator creates a tree enumerator with rounded corners.
719func roundedEnumerator(lPadding, width int) tree.Enumerator {
720 if width == 0 {
721 width = 2
722 }
723 if lPadding == 0 {
724 lPadding = 1
725 }
726 return func(children tree.Children, index int) string {
727 line := strings.Repeat("─", width)
728 padding := strings.Repeat(" ", lPadding)
729 if children.Length()-1 == index {
730 return padding + "╰" + line
731 }
732 return padding + "├" + line
733 }
734}
735
736// toolOutputMarkdownContent renders markdown content with optional truncation.
737func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
738 content = strings.ReplaceAll(content, "\r\n", "\n")
739 content = strings.ReplaceAll(content, "\t", " ")
740 content = strings.TrimSpace(content)
741
742 // Cap width for readability.
743 if width > maxTextWidth {
744 width = maxTextWidth
745 }
746
747 renderer := common.PlainMarkdownRenderer(sty, width)
748 rendered, err := renderer.Render(content)
749 if err != nil {
750 return toolOutputPlainContent(sty, content, width, expanded)
751 }
752
753 lines := strings.Split(rendered, "\n")
754 maxLines := responseContextHeight
755 if expanded {
756 maxLines = len(lines)
757 }
758
759 var out []string
760 for i, ln := range lines {
761 if i >= maxLines {
762 break
763 }
764 out = append(out, ln)
765 }
766
767 if len(lines) > maxLines && !expanded {
768 out = append(out, sty.Tool.ContentTruncation.
769 Width(width).
770 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
771 )
772 }
773
774 return sty.Tool.Body.Render(strings.Join(out, "\n"))
775}