on main help sign in
refactor(ui): chat: abstract tool message rendering
commit41b819f5204422eaf842d04454c4a1609ef07ae2 authorAyman Bagabas <ayman.bagabas@gmail.com> dateThu 18 Dec 2025, 13:54 (1 year ago) parent2cccd51
4 files changed+113−42
internal/ui/chat/bash.go+31−2 internal/ui/chat/messages.go−12 internal/ui/chat/tools.go+80−26 internal/ui/model/ui.go+2−2
internal/ui/chat/bash.go+31−2
@@ -5,11 +5,40 @@ import (
55 "strings"
66
77 "github.com/charmbracelet/crush/internal/agent/tools"
8+ "github.com/charmbracelet/crush/internal/message"
89 "github.com/charmbracelet/crush/internal/ui/styles"
910)
1011
11−// BashToolRenderer renders a bash tool call.
12−func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
12+// BashToolMessageItem is a message item that represents a bash tool call.
13+type BashToolMessageItem struct {
14+ *baseToolMessageItem
15+}
16+
17+var _ ToolMessageItem = (*BashToolMessageItem)(nil)
18+
19+// NewBashToolMessageItem creates a new [BashToolMessageItem].
20+func NewBashToolMessageItem(
21+ sty *styles.Styles,
22+ toolCall message.ToolCall,
23+ result *message.ToolResult,
24+ canceled bool,
25+) ToolMessageItem {
26+ return newBaseToolMessageItem(
27+ sty,
28+ toolCall,
29+ result,
30+ &BashToolRenderContext{},
31+ canceled,
32+ )
33+}
34+
35+// BashToolRenderContext holds context for rendering bash tool messages.
36+//
37+// It implements the [ToolRenderer] interface.
38+type BashToolRenderContext struct{}
39+
40+// RenderTool implements the [ToolRenderer] interface.
41+func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
1342 cappedWidth := cappedMessageWidth(width)
1443 const toolName = "Bash"
1544 if !opts.ToolCall.Finished && !opts.Canceled {
internal/ui/chat/messages.go−12
@@ -8,7 +8,6 @@ import (
88 "strings"
99
1010 tea "charm.land/bubbletea/v2"
11− "github.com/charmbracelet/crush/internal/agent/tools"
1211 "github.com/charmbracelet/crush/internal/message"
1312 "github.com/charmbracelet/crush/internal/ui/anim"
1413 "github.com/charmbracelet/crush/internal/ui/list"
@@ -182,17 +181,6 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
182181 return []MessageItem{}
183182}
184183
185−// ToolRenderer returns the appropriate [ToolRenderFunc] for a given tool call.
186−// this should be used for nested tools as well.
187−func ToolRenderer(tc message.ToolCall) ToolRenderFunc {
188− switch tc.Name {
189− case tools.BashToolName:
190− return BashToolRenderer
191− default:
192− return DefaultToolRenderer
193− }
194−}
195−
196184// shouldRenderAssistantMessage determines if an assistant message should be rendered
197185//
198186// In some cases the assistant message only has tools so we do not want to render an
internal/ui/chat/tools.go+80−26
@@ -30,6 +30,23 @@ const (
3030 ToolStatusCanceled
3131)
3232
33+// ToolMessageItem represents a tool call message in the chat UI.
34+type ToolMessageItem interface {
35+ MessageItem
36+
37+ ToolCall() message.ToolCall
38+ SetToolCall(tc message.ToolCall)
39+ SetResult(res *message.ToolResult)
40+}
41+
42+// DefaultToolRenderContext implements the default [ToolRenderer] interface.
43+type DefaultToolRenderContext struct{}
44+
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
48+}
49+
3350// ToolRenderOpts contains the data needed to render a tool call.
3451type ToolRenderOpts struct {
3552 ToolCall message.ToolCall
@@ -60,21 +77,26 @@ func (opts *ToolRenderOpts) Status() ToolStatus {
6077 return ToolStatusRunning
6178}
6279
63−// ToolRenderFunc is a function that renders a tool call to a string.
64−type ToolRenderFunc func(sty *styles.Styles, width int, t *ToolRenderOpts) string
80+// ToolRenderer represents an interface for rendering tool calls.
81+type ToolRenderer interface {
82+ RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
83+}
6584
66−// DefaultToolRenderer is a placeholder renderer for tools without a custom renderer.
67−func DefaultToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
68− return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
85+// ToolRendererFunc is a function type that implements the [ToolRenderer] interface.
86+type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string
87+
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)
6991}
7092
71−// ToolMessageItem represents a tool call message that can be displayed in the UI.
72−type ToolMessageItem struct {
93+// baseToolMessageItem represents a tool call message that can be displayed in the UI.
94+type baseToolMessageItem struct {
7395 *highlightableMessageItem
7496 *cachedMessageItem
7597 *focusableMessageItem
7698
77− renderFunc ToolRenderFunc
99+ toolRenderer ToolRenderer
78100 toolCall message.ToolCall
79101 result *message.ToolResult
80102 canceled bool
@@ -89,21 +111,23 @@ type ToolMessageItem struct {
89111 expanded bool
90112}
91113
92−// NewToolMessageItem creates a new tool message item with the given renderFunc.
93−func NewToolMessageItem(
114+// newBaseToolMessageItem is the internal constructor for base tool message items.
115+func newBaseToolMessageItem(
94116 sty *styles.Styles,
95117 toolCall message.ToolCall,
96118 result *message.ToolResult,
119+ toolRenderer ToolRenderer,
97120 canceled bool,
98−) *ToolMessageItem {
121+) *baseToolMessageItem {
99122 // we only do full width for diffs (as far as I know)
100123 hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
101− t := &ToolMessageItem{
124+
125+ t := &baseToolMessageItem{
102126 highlightableMessageItem: defaultHighlighter(sty),
103127 cachedMessageItem: &cachedMessageItem{},
104128 focusableMessageItem: &focusableMessageItem{},
105129 sty: sty,
106− renderFunc: ToolRenderer(toolCall),
130+ toolRenderer: toolRenderer,
107131 toolCall: toolCall,
108132 result: result,
109133 canceled: canceled,
@@ -117,16 +141,42 @@ func NewToolMessageItem(
117141 LabelColor: sty.FgBase,
118142 CycleColors: true,
119143 })
144+
120145 return t
121146}
122147
148+// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
149+//
150+// It returns a specific tool message item type if implemented, otherwise it
151+// returns a generic tool message item.
152+func NewToolMessageItem(
153+ sty *styles.Styles,
154+ toolCall message.ToolCall,
155+ result *message.ToolResult,
156+ canceled bool,
157+) ToolMessageItem {
158+ switch toolCall.Name {
159+ case tools.BashToolName:
160+ return NewBashToolMessageItem(sty, toolCall, result, canceled)
161+ default:
162+ // TODO: Implement other tool items
163+ return newBaseToolMessageItem(
164+ sty,
165+ toolCall,
166+ result,
167+ &DefaultToolRenderContext{},
168+ canceled,
169+ )
170+ }
171+}
172+
123173// ID returns the unique identifier for this tool message item.
124−func (t *ToolMessageItem) ID() string {
174+func (t *baseToolMessageItem) ID() string {
125175 return t.toolCall.ID
126176}
127177
128178// StartAnimation starts the assistant message animation if it should be spinning.
129−func (t *ToolMessageItem) StartAnimation() tea.Cmd {
179+func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
130180 if !t.isSpinning() {
131181 return nil
132182 }
@@ -134,7 +184,7 @@ func (t *ToolMessageItem) StartAnimation() tea.Cmd {
134184}
135185
136186// Animate progresses the assistant message animation if it should be spinning.
137−func (t *ToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
187+func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
138188 if !t.isSpinning() {
139189 return nil
140190 }
@@ -142,7 +192,7 @@ func (t *ToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
142192}
143193
144194// Render renders the tool message item at the given width.
145−func (t *ToolMessageItem) Render(width int) string {
195+func (t *baseToolMessageItem) Render(width int) string {
146196 toolItemWidth := width - messageLeftPaddingTotal
147197 if t.hasCappedWidth {
148198 toolItemWidth = cappedMessageWidth(width)
@@ -155,7 +205,7 @@ func (t *ToolMessageItem) Render(width int) string {
155205 content, height, ok := t.getCachedRender(toolItemWidth)
156206 // if we are spinning or there is no cache rerender
157207 if !ok || t.isSpinning() {
158− content = t.renderFunc(t.sty, toolItemWidth, &ToolRenderOpts{
208+ content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
159209 ToolCall: t.toolCall,
160210 Result: t.result,
161211 Canceled: t.canceled,
@@ -175,47 +225,51 @@ func (t *ToolMessageItem) Render(width int) string {
175225}
176226
177227// ToolCall returns the tool call associated with this message item.
178−func (t *ToolMessageItem) ToolCall() message.ToolCall {
228+func (t *baseToolMessageItem) ToolCall() message.ToolCall {
179229 return t.toolCall
180230}
181231
182232// SetToolCall sets the tool call associated with this message item.
183−func (t *ToolMessageItem) SetToolCall(tc message.ToolCall) {
233+func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
184234 t.toolCall = tc
185235 t.clearCache()
186236}
187237
188238// SetResult sets the tool result associated with this message item.
189−func (t *ToolMessageItem) SetResult(res *message.ToolResult) {
239+func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
190240 t.result = res
191241 t.clearCache()
192242}
193243
194244// SetPermissionRequested sets whether permission has been requested for this tool call.
195−func (t *ToolMessageItem) SetPermissionRequested(requested bool) {
245+// TODO: Consider merging with SetPermissionGranted and add an interface for
246+// permission management.
247+func (t *baseToolMessageItem) SetPermissionRequested(requested bool) {
196248 t.permissionRequested = requested
197249 t.clearCache()
198250}
199251
200252// SetPermissionGranted sets whether permission has been granted for this tool call.
201−func (t *ToolMessageItem) SetPermissionGranted(granted bool) {
253+// TODO: Consider merging with SetPermissionRequested and add an interface for
254+// permission management.
255+func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
202256 t.permissionGranted = granted
203257 t.clearCache()
204258}
205259
206260// isSpinning returns true if the tool should show animation.
207−func (t *ToolMessageItem) isSpinning() bool {
261+func (t *baseToolMessageItem) isSpinning() bool {
208262 return !t.toolCall.Finished && !t.canceled
209263}
210264
211265// ToggleExpanded toggles the expanded state of the thinking box.
212−func (t *ToolMessageItem) ToggleExpanded() {
266+func (t *baseToolMessageItem) ToggleExpanded() {
213267 t.expanded = !t.expanded
214268 t.clearCache()
215269}
216270
217271// HandleMouseClick implements MouseClickable.
218−func (t *ToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
272+func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
219273 if btn != ansi.MouseLeft {
220274 return false
221275 }
internal/ui/model/ui.go+2−2
@@ -426,7 +426,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
426426 // we should have an item!
427427 continue
428428 }
429− if toolMsgItem, ok := toolItem.(*chat.ToolMessageItem); ok {
429+ if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
430430 toolMsgItem.SetResult(&tr)
431431 }
432432 }
@@ -451,7 +451,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
451451 var items []chat.MessageItem
452452 for _, tc := range msg.ToolCalls() {
453453 existingToolItem := m.chat.MessageItem(tc.ID)
454− if toolItem, ok := existingToolItem.(*chat.ToolMessageItem); ok {
454+ if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
455455 existingToolCall := toolItem.ToolCall()
456456 // only update if finished state changed or input changed
457457 // to avoid clearing the cache