refactor(chat): initial setup for tool calls

Kujtim Hoxha created

Change summary

internal/ui/chat/bash.go     |  72 ++++++++
internal/ui/chat/messages.go |  24 ++
internal/ui/chat/tools.go    | 329 ++++++++++++++++++++++++++++++++++++++
internal/ui/styles/styles.go |  10 
4 files changed, 433 insertions(+), 2 deletions(-)

Detailed changes

internal/ui/chat/bash.go 🔗

@@ -0,0 +1,72 @@
+package chat
+
+import (
+	"encoding/json"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// BashToolRenderer renders a bash tool call.
+func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	const toolName = "Bash"
+	if !opts.ToolCall.Finished && !opts.Canceled {
+		return pendingTool(sty, toolName, opts.Anim)
+	}
+
+	var params tools.BashParams
+	var cmd string
+	err := json.Unmarshal([]byte(opts.ToolCall.Input), &params)
+
+	if err != nil {
+		cmd = "failed to parse command"
+	} else {
+		cmd = strings.ReplaceAll(params.Command, "\n", " ")
+		cmd = strings.ReplaceAll(cmd, "\t", "    ")
+	}
+
+	toolParams := []string{
+		cmd,
+	}
+
+	if params.RunInBackground {
+		toolParams = append(toolParams, "background", "true")
+	}
+
+	header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
+	earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth)
+
+	// If this is OK that means that the tool is not done yet or it was canceled
+	if ok {
+		return strings.Join([]string{header, "", earlyStateContent}, "\n")
+	}
+
+	if opts.Result == nil {
+		// We should not get here!
+		return header
+	}
+
+	var meta tools.BashResponseMetadata
+	err = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
+
+	var output string
+	if err != nil {
+		output = "failed to parse output"
+	}
+	output = meta.Output
+	if output == "" && opts.Result.Content != tools.BashNoOutput {
+		output = opts.Result.Content
+	}
+
+	if output == "" {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+
+	output = sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
+
+	return strings.Join([]string{header, "", output}, "\n")
+}

internal/ui/chat/messages.go 🔗

@@ -8,6 +8,7 @@ import (
 	"strings"
 
 	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/anim"
 	"github.com/charmbracelet/crush/internal/ui/list"
@@ -164,6 +165,29 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
 		if shouldRenderAssistantMessage(msg) {
 			items = append(items, NewAssistantMessageItem(sty, msg))
 		}
+		for _, tc := range msg.ToolCalls() {
+			var result *message.ToolResult
+			if tr, ok := toolResults[tc.ID]; ok {
+				result = &tr
+			}
+			renderFunc := DefaultToolRenderer
+			// we only do full width for diffs (as far as I know)
+			cappedWidth := true
+			switch tc.Name {
+			case tools.BashToolName:
+				renderFunc = BashToolRenderer
+			}
+
+			items = append(items, NewToolMessageItem(
+				sty,
+				renderFunc,
+				tc,
+				result,
+				msg.FinishReason() == message.FinishReasonCanceled,
+				cappedWidth,
+			))
+
+		}
 		return items
 	}
 	return []MessageItem{}

internal/ui/chat/tools.go 🔗

@@ -0,0 +1,329 @@
+package chat
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// responseContextHeight limits the number of lines displayed in tool output.
+const responseContextHeight = 10
+
+// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
+const toolBodyLeftPaddingTotal = 2
+
+// ToolStatus represents the current state of a tool call.
+type ToolStatus int
+
+const (
+	ToolStatusAwaitingPermission ToolStatus = iota
+	ToolStatusRunning
+	ToolStatusSuccess
+	ToolStatusError
+	ToolStatusCanceled
+)
+
+// ToolRenderOpts contains the data needed to render a tool call.
+type ToolRenderOpts struct {
+	ToolCall            message.ToolCall
+	Result              *message.ToolResult
+	Canceled            bool
+	Anim                *anim.Anim
+	Expanded            bool
+	IsSpinning          bool
+	PermissionRequested bool
+	PermissionGranted   bool
+}
+
+// Status returns the current status of the tool call.
+func (opts *ToolRenderOpts) Status() ToolStatus {
+	if opts.Canceled {
+		return ToolStatusCanceled
+	}
+	if opts.Result != nil {
+		if opts.Result.IsError {
+			return ToolStatusError
+		}
+		return ToolStatusSuccess
+	}
+	if opts.PermissionRequested && !opts.PermissionGranted {
+		return ToolStatusAwaitingPermission
+	}
+	return ToolStatusRunning
+}
+
+// ToolRenderFunc is a function that renders a tool call to a string.
+type ToolRenderFunc func(sty *styles.Styles, width int, t *ToolRenderOpts) string
+
+// DefaultToolRenderer is a placeholder renderer for tools without a custom renderer.
+func DefaultToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
+}
+
+// ToolMessageItem represents a tool call message that can be displayed in the UI.
+type ToolMessageItem struct {
+	*highlightableMessageItem
+	*cachedMessageItem
+	*focusableMessageItem
+
+	renderFunc ToolRenderFunc
+	toolCall   message.ToolCall
+	result     *message.ToolResult
+	canceled   bool
+	// we use this so we can efficiently cache
+	// tools that have a capped width (e.x bash.. and others)
+	hasCappedWidth bool
+
+	sty      *styles.Styles
+	anim     *anim.Anim
+	expanded bool
+}
+
+// NewToolMessageItem creates a new tool message item with the given renderFunc.
+func NewToolMessageItem(
+	sty *styles.Styles,
+	renderFunc ToolRenderFunc,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+	hasCappedWidth bool,
+) *ToolMessageItem {
+	t := &ToolMessageItem{
+		highlightableMessageItem: defaultHighlighter(sty),
+		cachedMessageItem:        &cachedMessageItem{},
+		focusableMessageItem:     &focusableMessageItem{},
+		sty:                      sty,
+		renderFunc:               renderFunc,
+		toolCall:                 toolCall,
+		result:                   result,
+		canceled:                 canceled,
+		hasCappedWidth:           hasCappedWidth,
+	}
+	t.anim = anim.New(anim.Settings{
+		ID:          toolCall.ID,
+		Size:        15,
+		GradColorA:  sty.Primary,
+		GradColorB:  sty.Secondary,
+		LabelColor:  sty.FgBase,
+		CycleColors: true,
+	})
+	return t
+}
+
+// ID returns the unique identifier for this tool message item.
+func (t *ToolMessageItem) ID() string {
+	return t.toolCall.ID
+}
+
+// StartAnimation starts the assistant message animation if it should be spinning.
+func (t *ToolMessageItem) StartAnimation() tea.Cmd {
+	if !t.isSpinning() {
+		return nil
+	}
+	return t.anim.Start()
+}
+
+// Animate progresses the assistant message animation if it should be spinning.
+func (t *ToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
+	if !t.isSpinning() {
+		return nil
+	}
+	return t.anim.Animate(msg)
+}
+
+// Render renders the tool message item at the given width.
+func (t *ToolMessageItem) Render(width int) string {
+	toolItemWidth := width - messageLeftPaddingTotal
+	if t.hasCappedWidth {
+		toolItemWidth = cappedMessageWidth(width)
+	}
+	style := t.sty.Chat.Message.ToolCallBlurred
+	if t.focused {
+		style = t.sty.Chat.Message.ToolCallFocused
+	}
+
+	content, height, ok := t.getCachedRender(toolItemWidth)
+	// if we are spinning or there is no cache rerender
+	if !ok || t.isSpinning() {
+		content = t.renderFunc(t.sty, toolItemWidth, &ToolRenderOpts{
+			ToolCall:   t.toolCall,
+			Result:     t.result,
+			Canceled:   t.canceled,
+			Anim:       t.anim,
+			Expanded:   t.expanded,
+			IsSpinning: t.isSpinning(),
+		})
+		height = lipgloss.Height(content)
+		// cache the rendered content
+		t.setCachedRender(content, toolItemWidth, height)
+	}
+
+	highlightedContent := t.renderHighlighted(content, toolItemWidth, height)
+	return style.Render(highlightedContent)
+}
+
+// isSpinning returns true if the tool should show animation.
+func (t *ToolMessageItem) isSpinning() bool {
+	return !t.toolCall.Finished && !t.canceled
+}
+
+// ToggleExpanded toggles the expanded state of the thinking box.
+func (t *ToolMessageItem) ToggleExpanded() {
+	t.expanded = !t.expanded
+	t.clearCache()
+}
+
+// HandleMouseClick implements MouseClickable.
+func (t *ToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
+	if btn != ansi.MouseLeft {
+		return false
+	}
+	t.ToggleExpanded()
+	return true
+}
+
+// pendingTool renders a tool that is still in progress with an animation.
+func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
+	icon := sty.Tool.IconPending.Render()
+	toolName := sty.Tool.NameNormal.Render(name)
+
+	var animView string
+	if anim != nil {
+		animView = anim.Render()
+	}
+
+	return fmt.Sprintf("%s %s %s", icon, toolName, animView)
+}
+
+// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
+// Returns the rendered output and true if early state was handled.
+func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
+	var msg string
+	switch opts.Status() {
+	case ToolStatusError:
+		msg = toolErrorContent(sty, opts.Result, width)
+	case ToolStatusCanceled:
+		msg = sty.Tool.StateCancelled.Render("Canceled.")
+	case ToolStatusAwaitingPermission:
+		msg = sty.Tool.StateWaiting.Render("Requesting permission...")
+	case ToolStatusRunning:
+		msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
+	default:
+		return "", false
+	}
+	return msg, true
+}
+
+// toolErrorContent formats an error message with ERROR tag.
+func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
+	if result == nil {
+		return ""
+	}
+	errContent := strings.ReplaceAll(result.Content, "\n", " ")
+	errTag := sty.Tool.ErrorTag.Render("ERROR")
+	tagWidth := lipgloss.Width(errTag)
+	errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
+	return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
+}
+
+// toolIcon returns the status icon for a tool call.
+// toolIcon returns the status icon for a tool call based on its status.
+func toolIcon(sty *styles.Styles, status ToolStatus) string {
+	switch status {
+	case ToolStatusSuccess:
+		return sty.Tool.IconSuccess.String()
+	case ToolStatusError:
+		return sty.Tool.IconError.String()
+	case ToolStatusCanceled:
+		return sty.Tool.IconCancelled.String()
+	default:
+		return sty.Tool.IconPending.String()
+	}
+}
+
+// toolParamList formats parameters as "main (key=value, ...)" with truncation.
+// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
+func toolParamList(sty *styles.Styles, params []string, width int) string {
+	// minSpaceForMainParam is the min space required for the main param
+	// if this is less that the value set we will only show the main param nothing else
+	const minSpaceForMainParam = 30
+	if len(params) == 0 {
+		return ""
+	}
+
+	mainParam := params[0]
+
+	// Build key=value pairs from remaining params (consecutive key, value pairs).
+	var kvPairs []string
+	for i := 1; i+1 < len(params); i += 2 {
+		if params[i+1] != "" {
+			kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
+		}
+	}
+
+	// Try to include key=value pairs if there's enough space.
+	output := mainParam
+	if len(kvPairs) > 0 {
+		partsStr := strings.Join(kvPairs, ", ")
+		if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
+			output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
+		}
+	}
+
+	if width >= 0 {
+		output = ansi.Truncate(output, width, "…")
+	}
+	return sty.Tool.ParamMain.Render(output)
+}
+
+// toolHeader builds the tool header line: "● ToolName params..."
+func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, params ...string) string {
+	icon := toolIcon(sty, status)
+	toolName := sty.Tool.NameNested.Render(name)
+	prefix := fmt.Sprintf("%s %s ", icon, toolName)
+	prefixWidth := lipgloss.Width(prefix)
+	remainingWidth := width - prefixWidth
+	paramsStr := toolParamList(sty, params, remainingWidth)
+	return prefix + paramsStr
+}
+
+// toolOutputPlainContent renders plain text with optional expansion support.
+func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	content = strings.ReplaceAll(content, "\t", "    ")
+	content = strings.TrimSpace(content)
+	lines := strings.Split(content, "\n")
+
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines) // Show all
+	}
+
+	var out []string
+	for i, ln := range lines {
+		if i >= maxLines {
+			break
+		}
+		ln = " " + ln
+		if lipgloss.Width(ln) > width {
+			ln = ansi.Truncate(ln, width, "…")
+		}
+		out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
+	}
+
+	wasTruncated := len(lines) > responseContextHeight
+
+	if !expanded && wasTruncated {
+		out = append(out, sty.Tool.ContentTruncation.
+			Width(width).
+			Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
+	}
+
+	return strings.Join(out, "\n")
+}

internal/ui/styles/styles.go 🔗

@@ -26,6 +26,8 @@ const (
 	DocumentIcon string = "🖼"
 	ModelIcon    string = "◇"
 
+	ArrowRightIcon string = "→"
+
 	ToolPending string = "●"
 	ToolSuccess string = "✓"
 	ToolError   string = "×"
@@ -34,6 +36,10 @@ const (
 	BorderThick string = "▌"
 
 	SectionSeparator string = "─"
+
+	TodoCompletedIcon  string = "✓"
+	TodoPendingIcon    string = "•"
+	TodoInProgressIcon string = "→"
 )
 
 const (
@@ -227,7 +233,7 @@ type Styles struct {
 		ContentTruncation lipgloss.Style // Truncation message "… (N lines)"
 		ContentCodeLine   lipgloss.Style // Code line with background and width
 		ContentCodeBg     color.Color    // Background color for syntax highlighting
-		BodyPadding       lipgloss.Style // Body content padding (PaddingLeft(2))
+		Body              lipgloss.Style // Body content padding (PaddingLeft(2))
 
 		// Deprecated - kept for backward compatibility
 		ContentBg         lipgloss.Style // Content background
@@ -956,7 +962,7 @@ func DefaultStyles() Styles {
 	s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
 	s.Tool.ContentCodeLine = s.Base.Background(bgBaseLighter)
 	s.Tool.ContentCodeBg = bgBase
-	s.Tool.BodyPadding = base.PaddingLeft(2)
+	s.Tool.Body = base.PaddingLeft(2)
 
 	// Deprecated - kept for backward compatibility
 	s.Tool.ContentBg = s.Muted.Background(bgBaseLighter)