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