diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go new file mode 100644 index 0000000000000000000000000000000000000000..e933f319ff1f0b832d1af02524e8914927c66c0d --- /dev/null +++ b/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), ¶ms) + + 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") +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index fe1e0c8f891c14f90c7e58cf8a99deb16d165765..9c925e0719436b4289cc2984f740df64c49c68ec 100644 --- a/internal/ui/chat/messages.go +++ b/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{} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go new file mode 100644 index 0000000000000000000000000000000000000000..e35934400f374feb22941020e0cf7f389eab8254 --- /dev/null +++ b/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") +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index d6f96474c7daf3397ebebb9b819b2388790d7a95..21ace6a1e588f97a4be586cc01c5ecf3f0a88984 100644 --- a/internal/ui/styles/styles.go +++ b/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)