on main help sign in
refactor(chat): initial setup for tool calls
commit0896789b516e50c4a13c2ddc01a02fe4466c4bd8 authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 18 Dec 2025, 12:16 (1 year ago) parent4316ef3
4 files changed+433−2
internal/ui/chat/bash.go+72 internal/ui/chat/messages.go+24 internal/ui/chat/tools.go+329 internal/ui/styles/styles.go+8−2
internal/ui/chat/bash.gonew file+72
@@ -0,0 +1,72 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+
7+ "github.com/charmbracelet/crush/internal/agent/tools"
8+ "github.com/charmbracelet/crush/internal/ui/styles"
9+)
10+
11+// BashToolRenderer renders a bash tool call.
12+func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
13+ cappedWidth := cappedMessageWidth(width)
14+ const toolName = "Bash"
15+ if !opts.ToolCall.Finished && !opts.Canceled {
16+ return pendingTool(sty, toolName, opts.Anim)
17+ }
18+
19+ var params tools.BashParams
20+ var cmd string
21+ err := json.Unmarshal([]byte(opts.ToolCall.Input), &params)
22+
23+ if err != nil {
24+ cmd = "failed to parse command"
25+ } else {
26+ cmd = strings.ReplaceAll(params.Command, "\n", " ")
27+ cmd = strings.ReplaceAll(cmd, "\t", " ")
28+ }
29+
30+ toolParams := []string{
31+ cmd,
32+ }
33+
34+ if params.RunInBackground {
35+ toolParams = append(toolParams, "background", "true")
36+ }
37+
38+ header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
39+ earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth)
40+
41+ // If this is OK that means that the tool is not done yet or it was canceled
42+ if ok {
43+ return strings.Join([]string{header, "", earlyStateContent}, "\n")
44+ }
45+
46+ if opts.Result == nil {
47+ // We should not get here!
48+ return header
49+ }
50+
51+ var meta tools.BashResponseMetadata
52+ err = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
53+
54+ var output string
55+ if err != nil {
56+ output = "failed to parse output"
57+ }
58+ output = meta.Output
59+ if output == "" && opts.Result.Content != tools.BashNoOutput {
60+ output = opts.Result.Content
61+ }
62+
63+ if output == "" {
64+ return header
65+ }
66+
67+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
68+
69+ output = sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
70+
71+ return strings.Join([]string{header, "", output}, "\n")
72+}
internal/ui/chat/messages.go+24
@@ -8,6 +8,7 @@ import (
88 "strings"
99
1010 tea "charm.land/bubbletea/v2"
11+ "github.com/charmbracelet/crush/internal/agent/tools"
1112 "github.com/charmbracelet/crush/internal/message"
1213 "github.com/charmbracelet/crush/internal/ui/anim"
1314 "github.com/charmbracelet/crush/internal/ui/list"
@@ -164,6 +165,29 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
164165 if shouldRenderAssistantMessage(msg) {
165166 items = append(items, NewAssistantMessageItem(sty, msg))
166167 }
168+ for _, tc := range msg.ToolCalls() {
169+ var result *message.ToolResult
170+ if tr, ok := toolResults[tc.ID]; ok {
171+ result = &tr
172+ }
173+ renderFunc := DefaultToolRenderer
174+ // we only do full width for diffs (as far as I know)
175+ cappedWidth := true
176+ switch tc.Name {
177+ case tools.BashToolName:
178+ renderFunc = BashToolRenderer
179+ }
180+
181+ items = append(items, NewToolMessageItem(
182+ sty,
183+ renderFunc,
184+ tc,
185+ result,
186+ msg.FinishReason() == message.FinishReasonCanceled,
187+ cappedWidth,
188+ ))
189+
190+ }
167191 return items
168192 }
169193 return []MessageItem{}
internal/ui/chat/tools.gonew file+329
@@ -0,0 +1,329 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ tea "charm.land/bubbletea/v2"
8+ "charm.land/lipgloss/v2"
9+ "github.com/charmbracelet/crush/internal/message"
10+ "github.com/charmbracelet/crush/internal/ui/anim"
11+ "github.com/charmbracelet/crush/internal/ui/styles"
12+ "github.com/charmbracelet/x/ansi"
13+)
14+
15+// responseContextHeight limits the number of lines displayed in tool output.
16+const responseContextHeight = 10
17+
18+// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
19+const toolBodyLeftPaddingTotal = 2
20+
21+// ToolStatus represents the current state of a tool call.
22+type ToolStatus int
23+
24+const (
25+ ToolStatusAwaitingPermission ToolStatus = iota
26+ ToolStatusRunning
27+ ToolStatusSuccess
28+ ToolStatusError
29+ ToolStatusCanceled
30+)
31+
32+// ToolRenderOpts contains the data needed to render a tool call.
33+type ToolRenderOpts struct {
34+ ToolCall message.ToolCall
35+ Result *message.ToolResult
36+ Canceled bool
37+ Anim *anim.Anim
38+ Expanded bool
39+ IsSpinning bool
40+ PermissionRequested bool
41+ PermissionGranted bool
42+}
43+
44+// Status returns the current status of the tool call.
45+func (opts *ToolRenderOpts) Status() ToolStatus {
46+ if opts.Canceled {
47+ return ToolStatusCanceled
48+ }
49+ if opts.Result != nil {
50+ if opts.Result.IsError {
51+ return ToolStatusError
52+ }
53+ return ToolStatusSuccess
54+ }
55+ if opts.PermissionRequested && !opts.PermissionGranted {
56+ return ToolStatusAwaitingPermission
57+ }
58+ return ToolStatusRunning
59+}
60+
61+// ToolRenderFunc is a function that renders a tool call to a string.
62+type ToolRenderFunc func(sty *styles.Styles, width int, t *ToolRenderOpts) string
63+
64+// DefaultToolRenderer is a placeholder renderer for tools without a custom renderer.
65+func DefaultToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
66+ return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
67+}
68+
69+// ToolMessageItem represents a tool call message that can be displayed in the UI.
70+type ToolMessageItem struct {
71+ *highlightableMessageItem
72+ *cachedMessageItem
73+ *focusableMessageItem
74+
75+ renderFunc ToolRenderFunc
76+ toolCall message.ToolCall
77+ result *message.ToolResult
78+ canceled bool
79+ // we use this so we can efficiently cache
80+ // tools that have a capped width (e.x bash.. and others)
81+ hasCappedWidth bool
82+
83+ sty *styles.Styles
84+ anim *anim.Anim
85+ expanded bool
86+}
87+
88+// NewToolMessageItem creates a new tool message item with the given renderFunc.
89+func NewToolMessageItem(
90+ sty *styles.Styles,
91+ renderFunc ToolRenderFunc,
92+ toolCall message.ToolCall,
93+ result *message.ToolResult,
94+ canceled bool,
95+ hasCappedWidth bool,
96+) *ToolMessageItem {
97+ t := &ToolMessageItem{
98+ highlightableMessageItem: defaultHighlighter(sty),
99+ cachedMessageItem: &cachedMessageItem{},
100+ focusableMessageItem: &focusableMessageItem{},
101+ sty: sty,
102+ renderFunc: renderFunc,
103+ toolCall: toolCall,
104+ result: result,
105+ canceled: canceled,
106+ hasCappedWidth: hasCappedWidth,
107+ }
108+ t.anim = anim.New(anim.Settings{
109+ ID: toolCall.ID,
110+ Size: 15,
111+ GradColorA: sty.Primary,
112+ GradColorB: sty.Secondary,
113+ LabelColor: sty.FgBase,
114+ CycleColors: true,
115+ })
116+ return t
117+}
118+
119+// ID returns the unique identifier for this tool message item.
120+func (t *ToolMessageItem) ID() string {
121+ return t.toolCall.ID
122+}
123+
124+// StartAnimation starts the assistant message animation if it should be spinning.
125+func (t *ToolMessageItem) StartAnimation() tea.Cmd {
126+ if !t.isSpinning() {
127+ return nil
128+ }
129+ return t.anim.Start()
130+}
131+
132+// Animate progresses the assistant message animation if it should be spinning.
133+func (t *ToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
134+ if !t.isSpinning() {
135+ return nil
136+ }
137+ return t.anim.Animate(msg)
138+}
139+
140+// Render renders the tool message item at the given width.
141+func (t *ToolMessageItem) Render(width int) string {
142+ toolItemWidth := width - messageLeftPaddingTotal
143+ if t.hasCappedWidth {
144+ toolItemWidth = cappedMessageWidth(width)
145+ }
146+ style := t.sty.Chat.Message.ToolCallBlurred
147+ if t.focused {
148+ style = t.sty.Chat.Message.ToolCallFocused
149+ }
150+
151+ content, height, ok := t.getCachedRender(toolItemWidth)
152+ // if we are spinning or there is no cache rerender
153+ if !ok || t.isSpinning() {
154+ content = t.renderFunc(t.sty, toolItemWidth, &ToolRenderOpts{
155+ ToolCall: t.toolCall,
156+ Result: t.result,
157+ Canceled: t.canceled,
158+ Anim: t.anim,
159+ Expanded: t.expanded,
160+ IsSpinning: t.isSpinning(),
161+ })
162+ height = lipgloss.Height(content)
163+ // cache the rendered content
164+ t.setCachedRender(content, toolItemWidth, height)
165+ }
166+
167+ highlightedContent := t.renderHighlighted(content, toolItemWidth, height)
168+ return style.Render(highlightedContent)
169+}
170+
171+// isSpinning returns true if the tool should show animation.
172+func (t *ToolMessageItem) isSpinning() bool {
173+ return !t.toolCall.Finished && !t.canceled
174+}
175+
176+// ToggleExpanded toggles the expanded state of the thinking box.
177+func (t *ToolMessageItem) ToggleExpanded() {
178+ t.expanded = !t.expanded
179+ t.clearCache()
180+}
181+
182+// HandleMouseClick implements MouseClickable.
183+func (t *ToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
184+ if btn != ansi.MouseLeft {
185+ return false
186+ }
187+ t.ToggleExpanded()
188+ return true
189+}
190+
191+// pendingTool renders a tool that is still in progress with an animation.
192+func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
193+ icon := sty.Tool.IconPending.Render()
194+ toolName := sty.Tool.NameNormal.Render(name)
195+
196+ var animView string
197+ if anim != nil {
198+ animView = anim.Render()
199+ }
200+
201+ return fmt.Sprintf("%s %s %s", icon, toolName, animView)
202+}
203+
204+// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
205+// Returns the rendered output and true if early state was handled.
206+func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
207+ var msg string
208+ switch opts.Status() {
209+ case ToolStatusError:
210+ msg = toolErrorContent(sty, opts.Result, width)
211+ case ToolStatusCanceled:
212+ msg = sty.Tool.StateCancelled.Render("Canceled.")
213+ case ToolStatusAwaitingPermission:
214+ msg = sty.Tool.StateWaiting.Render("Requesting permission...")
215+ case ToolStatusRunning:
216+ msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
217+ default:
218+ return "", false
219+ }
220+ return msg, true
221+}
222+
223+// toolErrorContent formats an error message with ERROR tag.
224+func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
225+ if result == nil {
226+ return ""
227+ }
228+ errContent := strings.ReplaceAll(result.Content, "\n", " ")
229+ errTag := sty.Tool.ErrorTag.Render("ERROR")
230+ tagWidth := lipgloss.Width(errTag)
231+ errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
232+ return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
233+}
234+
235+// toolIcon returns the status icon for a tool call.
236+// toolIcon returns the status icon for a tool call based on its status.
237+func toolIcon(sty *styles.Styles, status ToolStatus) string {
238+ switch status {
239+ case ToolStatusSuccess:
240+ return sty.Tool.IconSuccess.String()
241+ case ToolStatusError:
242+ return sty.Tool.IconError.String()
243+ case ToolStatusCanceled:
244+ return sty.Tool.IconCancelled.String()
245+ default:
246+ return sty.Tool.IconPending.String()
247+ }
248+}
249+
250+// toolParamList formats parameters as "main (key=value, ...)" with truncation.
251+// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
252+func toolParamList(sty *styles.Styles, params []string, width int) string {
253+ // minSpaceForMainParam is the min space required for the main param
254+ // if this is less that the value set we will only show the main param nothing else
255+ const minSpaceForMainParam = 30
256+ if len(params) == 0 {
257+ return ""
258+ }
259+
260+ mainParam := params[0]
261+
262+ // Build key=value pairs from remaining params (consecutive key, value pairs).
263+ var kvPairs []string
264+ for i := 1; i+1 < len(params); i += 2 {
265+ if params[i+1] != "" {
266+ kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
267+ }
268+ }
269+
270+ // Try to include key=value pairs if there's enough space.
271+ output := mainParam
272+ if len(kvPairs) > 0 {
273+ partsStr := strings.Join(kvPairs, ", ")
274+ if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
275+ output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
276+ }
277+ }
278+
279+ if width >= 0 {
280+ output = ansi.Truncate(output, width, "…")
281+ }
282+ return sty.Tool.ParamMain.Render(output)
283+}
284+
285+// toolHeader builds the tool header line: "● ToolName params..."
286+func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, params ...string) string {
287+ icon := toolIcon(sty, status)
288+ toolName := sty.Tool.NameNested.Render(name)
289+ prefix := fmt.Sprintf("%s %s ", icon, toolName)
290+ prefixWidth := lipgloss.Width(prefix)
291+ remainingWidth := width - prefixWidth
292+ paramsStr := toolParamList(sty, params, remainingWidth)
293+ return prefix + paramsStr
294+}
295+
296+// toolOutputPlainContent renders plain text with optional expansion support.
297+func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
298+ content = strings.ReplaceAll(content, "\r\n", "\n")
299+ content = strings.ReplaceAll(content, "\t", " ")
300+ content = strings.TrimSpace(content)
301+ lines := strings.Split(content, "\n")
302+
303+ maxLines := responseContextHeight
304+ if expanded {
305+ maxLines = len(lines) // Show all
306+ }
307+
308+ var out []string
309+ for i, ln := range lines {
310+ if i >= maxLines {
311+ break
312+ }
313+ ln = " " + ln
314+ if lipgloss.Width(ln) > width {
315+ ln = ansi.Truncate(ln, width, "…")
316+ }
317+ out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
318+ }
319+
320+ wasTruncated := len(lines) > responseContextHeight
321+
322+ if !expanded && wasTruncated {
323+ out = append(out, sty.Tool.ContentTruncation.
324+ Width(width).
325+ Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
326+ }
327+
328+ return strings.Join(out, "\n")
329+}
internal/ui/styles/styles.go+8−2
@@ -26,6 +26,8 @@ const (
2626 DocumentIcon string = "🖼"
2727 ModelIcon string = "◇"
2828
29+ ArrowRightIcon string = "→"
30+
2931 ToolPending string = "●"
3032 ToolSuccess string = "✓"
3133 ToolError string = "×"
@@ -34,6 +36,10 @@ const (
3436 BorderThick string = "▌"
3537
3638 SectionSeparator string = "─"
39+
40+ TodoCompletedIcon string = "✓"
41+ TodoPendingIcon string = "•"
42+ TodoInProgressIcon string = "→"
3743)
3844
3945const (
@@ -227,7 +233,7 @@ type Styles struct {
227233 ContentTruncation lipgloss.Style // Truncation message "… (N lines)"
228234 ContentCodeLine lipgloss.Style // Code line with background and width
229235 ContentCodeBg color.Color // Background color for syntax highlighting
230− BodyPadding lipgloss.Style // Body content padding (PaddingLeft(2))
236+ Body lipgloss.Style // Body content padding (PaddingLeft(2))
231237
232238 // Deprecated - kept for backward compatibility
233239 ContentBg lipgloss.Style // Content background
@@ -956,7 +962,7 @@ func DefaultStyles() Styles {
956962 s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
957963 s.Tool.ContentCodeLine = s.Base.Background(bgBaseLighter)
958964 s.Tool.ContentCodeBg = bgBase
959− s.Tool.BodyPadding = base.PaddingLeft(2)
965+ s.Tool.Body = base.PaddingLeft(2)
960966
961967 // Deprecated - kept for backward compatibility
962968 s.Tool.ContentBg = s.Muted.Background(bgBaseLighter)