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