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