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