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