tools.go

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