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// RawRender implements [MessageItem].
291func (t *baseToolMessageItem) RawRender(width int) string {
292 toolItemWidth := width - messageLeftPaddingTotal
293 if t.hasCappedWidth {
294 toolItemWidth = cappedMessageWidth(width)
295 }
296
297 content, height, ok := t.getCachedRender(toolItemWidth)
298 // if we are spinning or there is no cache rerender
299 if !ok || t.isSpinning() {
300 content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
301 ToolCall: t.toolCall,
302 Result: t.result,
303 Anim: t.anim,
304 ExpandedContent: t.expandedContent,
305 Compact: t.isCompact,
306 IsSpinning: t.isSpinning(),
307 Status: t.computeStatus(),
308 })
309 height = lipgloss.Height(content)
310 // cache the rendered content
311 t.setCachedRender(content, toolItemWidth, height)
312 }
313
314 return t.renderHighlighted(content, toolItemWidth, height)
315}
316
317// Render renders the tool message item at the given width.
318func (t *baseToolMessageItem) Render(width int) string {
319 style := t.sty.Chat.Message.ToolCallBlurred
320 if t.focused {
321 style = t.sty.Chat.Message.ToolCallFocused
322 }
323
324 if t.isCompact {
325 style = t.sty.Chat.Message.ToolCallCompact
326 }
327
328 return style.Render(t.RawRender(width))
329}
330
331// ToolCall returns the tool call associated with this message item.
332func (t *baseToolMessageItem) ToolCall() message.ToolCall {
333 return t.toolCall
334}
335
336// SetToolCall sets the tool call associated with this message item.
337func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
338 t.toolCall = tc
339 t.clearCache()
340}
341
342// SetResult sets the tool result associated with this message item.
343func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
344 t.result = res
345 t.clearCache()
346}
347
348// MessageID returns the ID of the message containing this tool call.
349func (t *baseToolMessageItem) MessageID() string {
350 return t.messageID
351}
352
353// SetMessageID sets the ID of the message containing this tool call.
354func (t *baseToolMessageItem) SetMessageID(id string) {
355 t.messageID = id
356}
357
358// SetStatus sets the tool status.
359func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
360 t.status = status
361 t.clearCache()
362}
363
364// Status returns the current tool status.
365func (t *baseToolMessageItem) Status() ToolStatus {
366 return t.status
367}
368
369// computeStatus computes the effective status considering the result.
370func (t *baseToolMessageItem) computeStatus() ToolStatus {
371 if t.result != nil {
372 if t.result.IsError {
373 return ToolStatusError
374 }
375 return ToolStatusSuccess
376 }
377 return t.status
378}
379
380// isSpinning returns true if the tool should show animation.
381func (t *baseToolMessageItem) isSpinning() bool {
382 if t.spinningFunc != nil {
383 return t.spinningFunc(SpinningState{
384 ToolCall: t.toolCall,
385 Result: t.result,
386 Status: t.status,
387 })
388 }
389 return !t.toolCall.Finished && t.status != ToolStatusCanceled
390}
391
392// SetSpinningFunc sets a custom function to determine if the tool should spin.
393func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
394 t.spinningFunc = fn
395}
396
397// ToggleExpanded toggles the expanded state of the thinking box.
398func (t *baseToolMessageItem) ToggleExpanded() {
399 t.expandedContent = !t.expandedContent
400 t.clearCache()
401}
402
403// HandleMouseClick implements MouseClickable.
404func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
405 if btn != ansi.MouseLeft {
406 return false
407 }
408 t.ToggleExpanded()
409 return true
410}
411
412// pendingTool renders a tool that is still in progress with an animation.
413func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
414 icon := sty.Tool.IconPending.Render()
415 toolName := sty.Tool.NameNormal.Render(name)
416
417 var animView string
418 if anim != nil {
419 animView = anim.Render()
420 }
421
422 return fmt.Sprintf("%s %s %s", icon, toolName, animView)
423}
424
425// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
426// Returns the rendered output and true if early state was handled.
427func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
428 var msg string
429 switch opts.Status {
430 case ToolStatusError:
431 msg = toolErrorContent(sty, opts.Result, width)
432 case ToolStatusCanceled:
433 msg = sty.Tool.StateCancelled.Render("Canceled.")
434 case ToolStatusAwaitingPermission:
435 msg = sty.Tool.StateWaiting.Render("Requesting permission...")
436 case ToolStatusRunning:
437 msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
438 default:
439 return "", false
440 }
441 return msg, true
442}
443
444// toolErrorContent formats an error message with ERROR tag.
445func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
446 if result == nil {
447 return ""
448 }
449 errContent := strings.ReplaceAll(result.Content, "\n", " ")
450 errTag := sty.Tool.ErrorTag.Render("ERROR")
451 tagWidth := lipgloss.Width(errTag)
452 errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
453 return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
454}
455
456// toolIcon returns the status icon for a tool call.
457// toolIcon returns the status icon for a tool call based on its status.
458func toolIcon(sty *styles.Styles, status ToolStatus) string {
459 switch status {
460 case ToolStatusSuccess:
461 return sty.Tool.IconSuccess.String()
462 case ToolStatusError:
463 return sty.Tool.IconError.String()
464 case ToolStatusCanceled:
465 return sty.Tool.IconCancelled.String()
466 default:
467 return sty.Tool.IconPending.String()
468 }
469}
470
471// toolParamList formats parameters as "main (key=value, ...)" with truncation.
472// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
473func toolParamList(sty *styles.Styles, params []string, width int) string {
474 // minSpaceForMainParam is the min space required for the main param
475 // if this is less that the value set we will only show the main param nothing else
476 const minSpaceForMainParam = 30
477 if len(params) == 0 {
478 return ""
479 }
480
481 mainParam := params[0]
482
483 // Build key=value pairs from remaining params (consecutive key, value pairs).
484 var kvPairs []string
485 for i := 1; i+1 < len(params); i += 2 {
486 if params[i+1] != "" {
487 kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
488 }
489 }
490
491 // Try to include key=value pairs if there's enough space.
492 output := mainParam
493 if len(kvPairs) > 0 {
494 partsStr := strings.Join(kvPairs, ", ")
495 if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
496 output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
497 }
498 }
499
500 if width >= 0 {
501 output = ansi.Truncate(output, width, "…")
502 }
503 return sty.Tool.ParamMain.Render(output)
504}
505
506// toolHeader builds the tool header line: "● ToolName params..."
507func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string {
508 icon := toolIcon(sty, status)
509 nameStyle := sty.Tool.NameNormal
510 if nested {
511 nameStyle = sty.Tool.NameNested
512 }
513 toolName := nameStyle.Render(name)
514 prefix := fmt.Sprintf("%s %s ", icon, toolName)
515 prefixWidth := lipgloss.Width(prefix)
516 remainingWidth := width - prefixWidth
517 paramsStr := toolParamList(sty, params, remainingWidth)
518 return prefix + paramsStr
519}
520
521// toolOutputPlainContent renders plain text with optional expansion support.
522func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
523 content = strings.ReplaceAll(content, "\r\n", "\n")
524 content = strings.ReplaceAll(content, "\t", " ")
525 content = strings.TrimSpace(content)
526 lines := strings.Split(content, "\n")
527
528 maxLines := responseContextHeight
529 if expanded {
530 maxLines = len(lines) // Show all
531 }
532
533 var out []string
534 for i, ln := range lines {
535 if i >= maxLines {
536 break
537 }
538 ln = " " + ln
539 if lipgloss.Width(ln) > width {
540 ln = ansi.Truncate(ln, width, "…")
541 }
542 out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
543 }
544
545 wasTruncated := len(lines) > responseContextHeight
546
547 if !expanded && wasTruncated {
548 out = append(out, sty.Tool.ContentTruncation.
549 Width(width).
550 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight)))
551 }
552
553 return strings.Join(out, "\n")
554}
555
556// toolOutputCodeContent renders code with syntax highlighting and line numbers.
557func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
558 content = strings.ReplaceAll(content, "\r\n", "\n")
559 content = strings.ReplaceAll(content, "\t", " ")
560
561 lines := strings.Split(content, "\n")
562 maxLines := responseContextHeight
563 if expanded {
564 maxLines = len(lines)
565 }
566
567 // Truncate if needed.
568 displayLines := lines
569 if len(lines) > maxLines {
570 displayLines = lines[:maxLines]
571 }
572
573 bg := sty.Tool.ContentCodeBg
574 highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg)
575 highlightedLines := strings.Split(highlighted, "\n")
576
577 // Calculate line number width.
578 maxLineNumber := len(displayLines) + offset
579 maxDigits := getDigits(maxLineNumber)
580 numFmt := fmt.Sprintf("%%%dd", maxDigits)
581
582 bodyWidth := width - toolBodyLeftPaddingTotal
583 codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding
584
585 var out []string
586 for i, ln := range highlightedLines {
587 lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
588
589 if lipgloss.Width(ln) > codeWidth {
590 ln = ansi.Truncate(ln, codeWidth, "…")
591 }
592
593 codeLine := sty.Tool.ContentCodeLine.
594 Width(codeWidth).
595 PaddingLeft(2).
596 Render(ln)
597
598 out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
599 }
600
601 // Add truncation message if needed.
602 if len(lines) > maxLines && !expanded {
603 out = append(out, sty.Tool.ContentCodeTruncation.
604 Width(bodyWidth).
605 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
606 )
607 }
608
609 return sty.Tool.Body.Render(strings.Join(out, "\n"))
610}
611
612// toolOutputImageContent renders image data with size info.
613func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
614 dataSize := len(data) * 3 / 4
615 sizeStr := formatSize(dataSize)
616
617 loaded := sty.Base.Foreground(sty.Green).Render("Loaded")
618 arrow := sty.Base.Foreground(sty.GreenDark).Render("→")
619 typeStyled := sty.Base.Render(mediaType)
620 sizeStyled := sty.Subtle.Render(sizeStr)
621
622 return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled))
623}
624
625// getDigits returns the number of digits in a number.
626func getDigits(n int) int {
627 if n == 0 {
628 return 1
629 }
630 if n < 0 {
631 n = -n
632 }
633 digits := 0
634 for n > 0 {
635 n /= 10
636 digits++
637 }
638 return digits
639}
640
641// formatSize formats byte size into human readable format.
642func formatSize(bytes int) string {
643 const (
644 kb = 1024
645 mb = kb * 1024
646 )
647 switch {
648 case bytes >= mb:
649 return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
650 case bytes >= kb:
651 return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
652 default:
653 return fmt.Sprintf("%d B", bytes)
654 }
655}
656
657// toolOutputDiffContent renders a diff between old and new content.
658func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
659 bodyWidth := width - toolBodyLeftPaddingTotal
660
661 formatter := common.DiffFormatter(sty).
662 Before(file, oldContent).
663 After(file, newContent).
664 Width(bodyWidth)
665
666 // Use split view for wide terminals.
667 if width > maxTextWidth {
668 formatter = formatter.Split()
669 }
670
671 formatted := formatter.String()
672 lines := strings.Split(formatted, "\n")
673
674 // Truncate if needed.
675 maxLines := responseContextHeight
676 if expanded {
677 maxLines = len(lines)
678 }
679
680 if len(lines) > maxLines && !expanded {
681 truncMsg := sty.Tool.DiffTruncation.
682 Width(bodyWidth).
683 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
684 formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
685 }
686
687 return sty.Tool.Body.Render(formatted)
688}
689
690// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
691// Returns empty string if timeout is 0.
692func formatTimeout(timeout int) string {
693 if timeout == 0 {
694 return ""
695 }
696 return fmt.Sprintf("%ds", timeout)
697}
698
699// formatNonZero returns string representation of non-zero integers, empty string for zero.
700func formatNonZero(value int) string {
701 if value == 0 {
702 return ""
703 }
704 return fmt.Sprintf("%d", value)
705}
706
707// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
708func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
709 bodyWidth := width - toolBodyLeftPaddingTotal
710
711 formatter := common.DiffFormatter(sty).
712 Before(file, meta.OldContent).
713 After(file, meta.NewContent).
714 Width(bodyWidth)
715
716 // Use split view for wide terminals.
717 if width > maxTextWidth {
718 formatter = formatter.Split()
719 }
720
721 formatted := formatter.String()
722 lines := strings.Split(formatted, "\n")
723
724 // Truncate if needed.
725 maxLines := responseContextHeight
726 if expanded {
727 maxLines = len(lines)
728 }
729
730 if len(lines) > maxLines && !expanded {
731 truncMsg := sty.Tool.DiffTruncation.
732 Width(bodyWidth).
733 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
734 formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
735 }
736
737 // Add failed edits note if any exist.
738 if len(meta.EditsFailed) > 0 {
739 noteTag := sty.Tool.NoteTag.Render("Note")
740 noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
741 note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
742 formatted = formatted + "\n\n" + note
743 }
744
745 return sty.Tool.Body.Render(formatted)
746}
747
748// roundedEnumerator creates a tree enumerator with rounded corners.
749func roundedEnumerator(lPadding, width int) tree.Enumerator {
750 if width == 0 {
751 width = 2
752 }
753 if lPadding == 0 {
754 lPadding = 1
755 }
756 return func(children tree.Children, index int) string {
757 line := strings.Repeat("─", width)
758 padding := strings.Repeat(" ", lPadding)
759 if children.Length()-1 == index {
760 return padding + "╰" + line
761 }
762 return padding + "├" + line
763 }
764}
765
766// toolOutputMarkdownContent renders markdown content with optional truncation.
767func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
768 content = strings.ReplaceAll(content, "\r\n", "\n")
769 content = strings.ReplaceAll(content, "\t", " ")
770 content = strings.TrimSpace(content)
771
772 // Cap width for readability.
773 if width > maxTextWidth {
774 width = maxTextWidth
775 }
776
777 renderer := common.PlainMarkdownRenderer(sty, width)
778 rendered, err := renderer.Render(content)
779 if err != nil {
780 return toolOutputPlainContent(sty, content, width, expanded)
781 }
782
783 lines := strings.Split(rendered, "\n")
784 maxLines := responseContextHeight
785 if expanded {
786 maxLines = len(lines)
787 }
788
789 var out []string
790 for i, ln := range lines {
791 if i >= maxLines {
792 break
793 }
794 out = append(out, ln)
795 }
796
797 if len(lines) > maxLines && !expanded {
798 out = append(out, sty.Tool.ContentTruncation.
799 Width(width).
800 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
801 )
802 }
803
804 return sty.Tool.Body.Render(strings.Join(out, "\n"))
805}