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	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}