@@ -0,0 +1,418 @@
7+ tea "charm.land/bubbletea/v2"
8+ "charm.land/lipgloss/v2"
9+ "github.com/charmbracelet/crush/internal/agent/tools"
10+ "github.com/charmbracelet/crush/internal/message"
11+ "github.com/charmbracelet/crush/internal/ui/anim"
12+ "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/x/ansi"
16+// responseContextHeight limits the number of lines displayed in tool output.
17+const responseContextHeight = 10
19+// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
20+const toolBodyLeftPaddingTotal = 2
22+// ToolStatus represents the current state of a tool call.
26+ ToolStatusAwaitingPermission ToolStatus = iota
33+// ToolMessageItem represents a tool call message in the chat UI.
34+type ToolMessageItem interface {
37+ ToolCall() message.ToolCall
38+ SetToolCall(tc message.ToolCall)
39+ SetResult(res *message.ToolResult)
42+// DefaultToolRenderContext implements the default [ToolRenderer] interface.
43+type DefaultToolRenderContext struct{}
45+// RenderTool implements the [ToolRenderer] interface.
46+func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
47+ return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
50+// ToolRenderOpts contains the data needed to render a tool call.
51+type ToolRenderOpts struct {
52+ ToolCall message.ToolCall
53+ Result *message.ToolResult
59+ PermissionRequested bool
60+ PermissionGranted bool
63+// Status returns the current status of the tool call.
64+func (opts *ToolRenderOpts) Status() ToolStatus {
65+ if opts.Canceled && opts.Result == nil {
66+ return ToolStatusCanceled
68+ if opts.Result != nil {
69+ if opts.Result.IsError {
70+ return ToolStatusError
72+ return ToolStatusSuccess
74+ if opts.PermissionRequested && !opts.PermissionGranted {
75+ return ToolStatusAwaitingPermission
77+ return ToolStatusRunning
80+// ToolRenderer represents an interface for rendering tool calls.
81+type ToolRenderer interface {
82+ RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
85+// ToolRendererFunc is a function type that implements the [ToolRenderer] interface.
86+type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string
88+// RenderTool implements the ToolRenderer interface.
89+func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
90+ return f(sty, width, opts)
93+// baseToolMessageItem represents a tool call message that can be displayed in the UI.
94+type baseToolMessageItem struct {
95+ *highlightableMessageItem
97+ *focusableMessageItem
99+ toolRenderer ToolRenderer
100+ toolCall message.ToolCall
101+ result *message.ToolResult
103+ permissionRequested bool
104+ permissionGranted bool
105+ // we use this so we can efficiently cache
106+ // tools that have a capped width (e.x bash.. and others)
114+// newBaseToolMessageItem is the internal constructor for base tool message items.
115+func newBaseToolMessageItem(
117+ toolCall message.ToolCall,
118+ result *message.ToolResult,
119+ toolRenderer ToolRenderer,
121+) *baseToolMessageItem {
122+ // we only do full width for diffs (as far as I know)
123+ hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
125+ t := &baseToolMessageItem{
126+ highlightableMessageItem: defaultHighlighter(sty),
127+ cachedMessageItem: &cachedMessageItem{},
128+ focusableMessageItem: &focusableMessageItem{},
130+ toolRenderer: toolRenderer,
134+ hasCappedWidth: hasCappedWidth,
136+ t.anim = anim.New(anim.Settings{
139+ GradColorA: sty.Primary,
140+ GradColorB: sty.Secondary,
141+ LabelColor: sty.FgBase,
148+// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
150+// It returns a specific tool message item type if implemented, otherwise it
151+// returns a generic tool message item.
152+func NewToolMessageItem(
154+ toolCall message.ToolCall,
155+ result *message.ToolResult,
158+ switch toolCall.Name {
159+ case tools.BashToolName:
160+ return NewBashToolMessageItem(sty, toolCall, result, canceled)
162+ // TODO: Implement other tool items
163+ return newBaseToolMessageItem(
167+ &DefaultToolRenderContext{},
173+// ID returns the unique identifier for this tool message item.
174+func (t *baseToolMessageItem) ID() string {
175+ return t.toolCall.ID
178+// StartAnimation starts the assistant message animation if it should be spinning.
179+func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
180+ if !t.isSpinning() {
183+ return t.anim.Start()
186+// Animate progresses the assistant message animation if it should be spinning.
187+func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
188+ if !t.isSpinning() {
191+ return t.anim.Animate(msg)
194+// Render renders the tool message item at the given width.
195+func (t *baseToolMessageItem) Render(width int) string {
196+ toolItemWidth := width - messageLeftPaddingTotal
197+ if t.hasCappedWidth {
198+ toolItemWidth = cappedMessageWidth(width)
200+ style := t.sty.Chat.Message.ToolCallBlurred
202+ style = t.sty.Chat.Message.ToolCallFocused
205+ content, height, ok := t.getCachedRender(toolItemWidth)
206+ // if we are spinning or there is no cache rerender
207+ if !ok || t.isSpinning() {
208+ content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
209+ ToolCall: t.toolCall,
211+ Canceled: t.canceled,
213+ Expanded: t.expanded,
214+ PermissionRequested: t.permissionRequested,
215+ PermissionGranted: t.permissionGranted,
216+ IsSpinning: t.isSpinning(),
218+ height = lipgloss.Height(content)
219+ // cache the rendered content
220+ t.setCachedRender(content, toolItemWidth, height)
223+ highlightedContent := t.renderHighlighted(content, toolItemWidth, height)
224+ return style.Render(highlightedContent)
227+// ToolCall returns the tool call associated with this message item.
228+func (t *baseToolMessageItem) ToolCall() message.ToolCall {
232+// SetToolCall sets the tool call associated with this message item.
233+func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
238+// SetResult sets the tool result associated with this message item.
239+func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
244+// SetPermissionRequested sets whether permission has been requested for this tool call.
245+// TODO: Consider merging with SetPermissionGranted and add an interface for
246+// permission management.
247+func (t *baseToolMessageItem) SetPermissionRequested(requested bool) {
248+ t.permissionRequested = requested
252+// SetPermissionGranted sets whether permission has been granted for this tool call.
253+// TODO: Consider merging with SetPermissionRequested and add an interface for
254+// permission management.
255+func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
256+ t.permissionGranted = granted
260+// isSpinning returns true if the tool should show animation.
261+func (t *baseToolMessageItem) isSpinning() bool {
262+ return !t.toolCall.Finished && !t.canceled
265+// ToggleExpanded toggles the expanded state of the thinking box.
266+func (t *baseToolMessageItem) ToggleExpanded() {
267+ t.expanded = !t.expanded
271+// HandleMouseClick implements MouseClickable.
272+func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
273+ if btn != ansi.MouseLeft {
280+// pendingTool renders a tool that is still in progress with an animation.
281+func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
282+ icon := sty.Tool.IconPending.Render()
283+ toolName := sty.Tool.NameNormal.Render(name)
287+ animView = anim.Render()
290+ return fmt.Sprintf("%s %s %s", icon, toolName, animView)
293+// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
294+// Returns the rendered output and true if early state was handled.
295+func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
297+ switch opts.Status() {
298+ case ToolStatusError:
299+ msg = toolErrorContent(sty, opts.Result, width)
300+ case ToolStatusCanceled:
301+ msg = sty.Tool.StateCancelled.Render("Canceled.")
302+ case ToolStatusAwaitingPermission:
303+ msg = sty.Tool.StateWaiting.Render("Requesting permission...")
304+ case ToolStatusRunning:
305+ msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
312+// toolErrorContent formats an error message with ERROR tag.
313+func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
317+ errContent := strings.ReplaceAll(result.Content, "\n", " ")
318+ errTag := sty.Tool.ErrorTag.Render("ERROR")
319+ tagWidth := lipgloss.Width(errTag)
320+ errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
321+ return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
324+// toolIcon returns the status icon for a tool call.
325+// toolIcon returns the status icon for a tool call based on its status.
326+func toolIcon(sty *styles.Styles, status ToolStatus) string {
328+ case ToolStatusSuccess:
329+ return sty.Tool.IconSuccess.String()
330+ case ToolStatusError:
331+ return sty.Tool.IconError.String()
332+ case ToolStatusCanceled:
333+ return sty.Tool.IconCancelled.String()
335+ return sty.Tool.IconPending.String()
339+// toolParamList formats parameters as "main (key=value, ...)" with truncation.
340+// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
341+func toolParamList(sty *styles.Styles, params []string, width int) string {
342+ // minSpaceForMainParam is the min space required for the main param
343+ // if this is less that the value set we will only show the main param nothing else
344+ const minSpaceForMainParam = 30
345+ if len(params) == 0 {
349+ mainParam := params[0]
351+ // Build key=value pairs from remaining params (consecutive key, value pairs).
352+ var kvPairs []string
353+ for i := 1; i+1 < len(params); i += 2 {
354+ if params[i+1] != "" {
355+ kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
359+ // Try to include key=value pairs if there's enough space.
361+ if len(kvPairs) > 0 {
362+ partsStr := strings.Join(kvPairs, ", ")
363+ if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
364+ output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
369+ output = ansi.Truncate(output, width, "…")
371+ return sty.Tool.ParamMain.Render(output)
374+// toolHeader builds the tool header line: "● ToolName params..."
375+func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, params ...string) string {
376+ icon := toolIcon(sty, status)
377+ toolName := sty.Tool.NameNested.Render(name)
378+ prefix := fmt.Sprintf("%s %s ", icon, toolName)
379+ prefixWidth := lipgloss.Width(prefix)
380+ remainingWidth := width - prefixWidth
381+ paramsStr := toolParamList(sty, params, remainingWidth)
382+ return prefix + paramsStr
385+// toolOutputPlainContent renders plain text with optional expansion support.
386+func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
387+ content = strings.ReplaceAll(content, "\r\n", "\n")
388+ content = strings.ReplaceAll(content, "\t", " ")
389+ content = strings.TrimSpace(content)
390+ lines := strings.Split(content, "\n")
392+ maxLines := responseContextHeight
394+ maxLines = len(lines) // Show all
398+ for i, ln := range lines {
403+ if lipgloss.Width(ln) > width {
404+ ln = ansi.Truncate(ln, width, "…")
406+ out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
409+ wasTruncated := len(lines) > responseContextHeight
411+ if !expanded && wasTruncated {
412+ out = append(out, sty.Tool.ContentTruncation.
414+ Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
417+ return strings.Join(out, "\n")