on main help sign in
refactor(ui): chat: abstract tool message rendering
41b819fAyman Bagabas
internal/ui/chat/bash.go+31−2
@@ -5,11 +5,40 @@ import (
5 "strings"
5 "strings"
6
6
7 "github.com/charmbracelet/crush/internal/agent/tools"
7 "github.com/charmbracelet/crush/internal/agent/tools"
8+ "github.com/charmbracelet/crush/internal/message"
8 "github.com/charmbracelet/crush/internal/ui/styles"
9 "github.com/charmbracelet/crush/internal/ui/styles"
9)
10)
10
11
11−// BashToolRenderer renders a bash tool call.
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.
12−func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
41+func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
13 cappedWidth := cappedMessageWidth(width)
42 cappedWidth := cappedMessageWidth(width)
14 const toolName = "Bash"
43 const toolName = "Bash"
15 if !opts.ToolCall.Finished && !opts.Canceled {
44 if !opts.ToolCall.Finished && !opts.Canceled {
internal/ui/chat/messages.go−12
@@ -8,7 +8,6 @@ import (
8 "strings"
8 "strings"
9
9
10 tea "charm.land/bubbletea/v2"
10 tea "charm.land/bubbletea/v2"
11− "github.com/charmbracelet/crush/internal/agent/tools"
12 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/ui/anim"
12 "github.com/charmbracelet/crush/internal/ui/anim"
14 "github.com/charmbracelet/crush/internal/ui/list"
13 "github.com/charmbracelet/crush/internal/ui/list"
@@ -182,17 +181,6 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
182 return []MessageItem{}
181 return []MessageItem{}
183}
182}
184
183
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−
196// shouldRenderAssistantMessage determines if an assistant message should be rendered
184// shouldRenderAssistantMessage determines if an assistant message should be rendered
197//
185//
198// In some cases the assistant message only has tools so we do not want to render an
186// 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 (
30 ToolStatusCanceled
30 ToolStatusCanceled
31)
31)
32
32
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+
33// ToolRenderOpts contains the data needed to render a tool call.
50// ToolRenderOpts contains the data needed to render a tool call.
34type ToolRenderOpts struct {
51type ToolRenderOpts struct {
35 ToolCall message.ToolCall
52 ToolCall message.ToolCall
@@ -60,21 +77,26 @@ func (opts *ToolRenderOpts) Status() ToolStatus {
60 return ToolStatusRunning
77 return ToolStatusRunning
61}
78}
62
79
63−// ToolRenderFunc is a function that renders a tool call to a string.
80+// ToolRenderer represents an interface for rendering tool calls.
81+type ToolRenderer interface {
64−type ToolRenderFunc func(sty *styles.Styles, width int, t *ToolRenderOpts) string
82+ RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
83+}
65
84
66−// DefaultToolRenderer is a placeholder renderer for tools without a custom renderer.
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.
67−func DefaultToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
89+func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
68− return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
90+ return f(sty, width, opts)
69}
91}
70
92
71−// ToolMessageItem represents a tool call message that can be displayed in the UI.
93+// baseToolMessageItem represents a tool call message that can be displayed in the UI.
72−type ToolMessageItem struct {
94+type baseToolMessageItem struct {
73 *highlightableMessageItem
95 *highlightableMessageItem
74 *cachedMessageItem
96 *cachedMessageItem
75 *focusableMessageItem
97 *focusableMessageItem
76
98
77− renderFunc ToolRenderFunc
99+ toolRenderer ToolRenderer
78 toolCall message.ToolCall
100 toolCall message.ToolCall
79 result *message.ToolResult
101 result *message.ToolResult
80 canceled bool
102 canceled bool
@@ -89,21 +111,23 @@ type ToolMessageItem struct {
89 expanded bool
111 expanded bool
90}
112}
91
113
92−// NewToolMessageItem creates a new tool message item with the given renderFunc.
114+// newBaseToolMessageItem is the internal constructor for base tool message items.
93−func NewToolMessageItem(
115+func newBaseToolMessageItem(
94 sty *styles.Styles,
116 sty *styles.Styles,
95 toolCall message.ToolCall,
117 toolCall message.ToolCall,
96 result *message.ToolResult,
118 result *message.ToolResult,
119+ toolRenderer ToolRenderer,
97 canceled bool,
120 canceled bool,
98−) *ToolMessageItem {
121+) *baseToolMessageItem {
99 // we only do full width for diffs (as far as I know)
122 // we only do full width for diffs (as far as I know)
100 hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
123 hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
101− t := &ToolMessageItem{
124+
125+ t := &baseToolMessageItem{
102 highlightableMessageItem: defaultHighlighter(sty),
126 highlightableMessageItem: defaultHighlighter(sty),
103 cachedMessageItem: &cachedMessageItem{},
127 cachedMessageItem: &cachedMessageItem{},
104 focusableMessageItem: &focusableMessageItem{},
128 focusableMessageItem: &focusableMessageItem{},
105 sty: sty,
129 sty: sty,
106− renderFunc: ToolRenderer(toolCall),
130+ toolRenderer: toolRenderer,
107 toolCall: toolCall,
131 toolCall: toolCall,
108 result: result,
132 result: result,
109 canceled: canceled,
133 canceled: canceled,
@@ -117,16 +141,42 @@ func NewToolMessageItem(
117 LabelColor: sty.FgBase,
141 LabelColor: sty.FgBase,
118 CycleColors: true,
142 CycleColors: true,
119 })
143 })
144+
120 return t
145 return t
121}
146}
122
147
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+
123// ID returns the unique identifier for this tool message item.
173// ID returns the unique identifier for this tool message item.
124−func (t *ToolMessageItem) ID() string {
174+func (t *baseToolMessageItem) ID() string {
125 return t.toolCall.ID
175 return t.toolCall.ID
126}
176}
127
177
128// StartAnimation starts the assistant message animation if it should be spinning.
178// 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 {
130 if !t.isSpinning() {
180 if !t.isSpinning() {
131 return nil
181 return nil
132 }
182 }
@@ -134,7 +184,7 @@ func (t *ToolMessageItem) StartAnimation() tea.Cmd {
134}
184}
135
185
136// Animate progresses the assistant message animation if it should be spinning.
186// 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 {
138 if !t.isSpinning() {
188 if !t.isSpinning() {
139 return nil
189 return nil
140 }
190 }
@@ -142,7 +192,7 @@ func (t *ToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
142}
192}
143
193
144// Render renders the tool message item at the given width.
194// 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 {
146 toolItemWidth := width - messageLeftPaddingTotal
196 toolItemWidth := width - messageLeftPaddingTotal
147 if t.hasCappedWidth {
197 if t.hasCappedWidth {
148 toolItemWidth = cappedMessageWidth(width)
198 toolItemWidth = cappedMessageWidth(width)
@@ -155,7 +205,7 @@ func (t *ToolMessageItem) Render(width int) string {
155 content, height, ok := t.getCachedRender(toolItemWidth)
205 content, height, ok := t.getCachedRender(toolItemWidth)
156 // if we are spinning or there is no cache rerender
206 // if we are spinning or there is no cache rerender
157 if !ok || t.isSpinning() {
207 if !ok || t.isSpinning() {
158− content = t.renderFunc(t.sty, toolItemWidth, &ToolRenderOpts{
208+ content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
159 ToolCall: t.toolCall,
209 ToolCall: t.toolCall,
160 Result: t.result,
210 Result: t.result,
161 Canceled: t.canceled,
211 Canceled: t.canceled,
@@ -175,47 +225,51 @@ func (t *ToolMessageItem) Render(width int) string {
175}
225}
176
226
177// ToolCall returns the tool call associated with this message item.
227// ToolCall returns the tool call associated with this message item.
178−func (t *ToolMessageItem) ToolCall() message.ToolCall {
228+func (t *baseToolMessageItem) ToolCall() message.ToolCall {
179 return t.toolCall
229 return t.toolCall
180}
230}
181
231
182// SetToolCall sets the tool call associated with this message item.
232// 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) {
184 t.toolCall = tc
234 t.toolCall = tc
185 t.clearCache()
235 t.clearCache()
186}
236}
187
237
188// SetResult sets the tool result associated with this message item.
238// 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) {
190 t.result = res
240 t.result = res
191 t.clearCache()
241 t.clearCache()
192}
242}
193
243
194// SetPermissionRequested sets whether permission has been requested for this tool call.
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.
195−func (t *ToolMessageItem) SetPermissionRequested(requested bool) {
247+func (t *baseToolMessageItem) SetPermissionRequested(requested bool) {
196 t.permissionRequested = requested
248 t.permissionRequested = requested
197 t.clearCache()
249 t.clearCache()
198}
250}
199
251
200// SetPermissionGranted sets whether permission has been granted for this tool call.
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.
201−func (t *ToolMessageItem) SetPermissionGranted(granted bool) {
255+func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
202 t.permissionGranted = granted
256 t.permissionGranted = granted
203 t.clearCache()
257 t.clearCache()
204}
258}
205
259
206// isSpinning returns true if the tool should show animation.
260// isSpinning returns true if the tool should show animation.
207−func (t *ToolMessageItem) isSpinning() bool {
261+func (t *baseToolMessageItem) isSpinning() bool {
208 return !t.toolCall.Finished && !t.canceled
262 return !t.toolCall.Finished && !t.canceled
209}
263}
210
264
211// ToggleExpanded toggles the expanded state of the thinking box.
265// ToggleExpanded toggles the expanded state of the thinking box.
212−func (t *ToolMessageItem) ToggleExpanded() {
266+func (t *baseToolMessageItem) ToggleExpanded() {
213 t.expanded = !t.expanded
267 t.expanded = !t.expanded
214 t.clearCache()
268 t.clearCache()
215}
269}
216
270
217// HandleMouseClick implements MouseClickable.
271// 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 {
219 if btn != ansi.MouseLeft {
273 if btn != ansi.MouseLeft {
220 return false
274 return false
221 }
275 }
internal/ui/model/ui.go+2−2
@@ -426,7 +426,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
426 // we should have an item!
426 // we should have an item!
427 continue
427 continue
428 }
428 }
429− if toolMsgItem, ok := toolItem.(*chat.ToolMessageItem); ok {
429+ if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
430 toolMsgItem.SetResult(&tr)
430 toolMsgItem.SetResult(&tr)
431 }
431 }
432 }
432 }
@@ -451,7 +451,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
451 var items []chat.MessageItem
451 var items []chat.MessageItem
452 for _, tc := range msg.ToolCalls() {
452 for _, tc := range msg.ToolCalls() {
453 existingToolItem := m.chat.MessageItem(tc.ID)
453 existingToolItem := m.chat.MessageItem(tc.ID)
454− if toolItem, ok := existingToolItem.(*chat.ToolMessageItem); ok {
454+ if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
455 existingToolCall := toolItem.ToolCall()
455 existingToolCall := toolItem.ToolCall()
456 // only update if finished state changed or input changed
456 // only update if finished state changed or input changed
457 // to avoid clearing the cache
457 // to avoid clearing the cache