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