on main help sign in
refactor(chat): add bash related tools
commit2f1feaa17247968b07c722b78fc2cc71ff33d929 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 19 Dec 2025, 12:20 (1 year ago) parent3474160
4 files changed+193−46
internal/ui/chat/bash.go+181−41 internal/ui/chat/tools.go+4 internal/ui/dialog/models_list.go+3−3 internal/ui/styles/styles.go+5−2
internal/ui/chat/bash.go+181−41
@@ -1,14 +1,22 @@
11package chat
22
33import (
4+ "cmp"
45 "encoding/json"
6+ "fmt"
57 "strings"
68
9+ "charm.land/lipgloss/v2"
710 "github.com/charmbracelet/crush/internal/agent/tools"
811 "github.com/charmbracelet/crush/internal/message"
912 "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/x/ansi"
1014)
1115
16+// -----------------------------------------------------------------------------
17+// Bash Tool
18+// -----------------------------------------------------------------------------
19+
1220// BashToolMessageItem is a message item that represents a bash tool call.
1321type BashToolMessageItem struct {
1422 *baseToolMessageItem
@@ -23,86 +31,218 @@ func NewBashToolMessageItem(
2331 result *message.ToolResult,
2432 canceled bool,
2533) ToolMessageItem {
26− return newBaseToolMessageItem(
27− sty,
28− toolCall,
29− result,
30− &BashToolRenderContext{},
31− canceled,
32− )
34+ return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled)
3335}
3436
35−// BashToolRenderContext holds context for rendering bash tool messages.
36−//
37−// It implements the [ToolRenderer] interface.
37+// BashToolRenderContext renders bash tool messages.
3838type BashToolRenderContext struct{}
3939
4040// RenderTool implements the [ToolRenderer] interface.
4141func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
4242 cappedWidth := cappedMessageWidth(width)
43− const toolName = "Bash"
4443 if !opts.ToolCall.Finished && !opts.Canceled {
45− return pendingTool(sty, toolName, opts.Anim)
44+ return pendingTool(sty, "Bash", opts.Anim)
4645 }
4746
4847 var params tools.BashParams
49− var cmd string
50− err := json.Unmarshal([]byte(opts.ToolCall.Input), &params)
51−
52− if err != nil {
53− cmd = "failed to parse command"
54− } else {
55− cmd = strings.ReplaceAll(params.Command, "\n", " ")
56− cmd = strings.ReplaceAll(cmd, "\t", " ")
48+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
49+ params.Command = "failed to parse command"
5750 }
5851
59− // TODO: if the tool is being run in the background use the background job renderer
52+ // Check if this is a background job.
53+ var meta tools.BashResponseMetadata
54+ if opts.Result != nil {
55+ _ = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
56+ }
6057
61− toolParams := []string{
62− cmd,
58+ if meta.Background {
59+ description := cmp.Or(meta.Description, params.Command)
60+ content := "Command: " + params.Command + "\n" + opts.Result.Content
61+ return renderJobTool(sty, opts, cappedWidth, "Start", meta.ShellID, description, content)
6362 }
6463
64+ // Regular bash command.
65+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
66+ cmd = strings.ReplaceAll(cmd, "\t", " ")
67+ toolParams := []string{cmd}
6568 if params.RunInBackground {
6669 toolParams = append(toolParams, "background", "true")
6770 }
6871
6972 header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
70−
7173 if opts.Nested {
7274 return header
7375 }
7476
75− earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth)
76−
77− // If this is OK that means that the tool is not done yet or it was canceled
78− if ok {
79− return strings.Join([]string{header, "", earlyStateContent}, "\n")
77+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
78+ return joinToolParts(header, earlyState)
8079 }
8180
8281 if opts.Result == nil {
83− // We should not get here!
8482 return header
8583 }
8684
87− var meta tools.BashResponseMetadata
88− err = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
89−
90− var output string
91− if err != nil {
92− output = "failed to parse output"
93− }
94− output = meta.Output
85+ output := meta.Output
9586 if output == "" && opts.Result.Content != tools.BashNoOutput {
9687 output = opts.Result.Content
9788 }
98−
9989 if output == "" {
10090 return header
10191 }
10292
10393 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
94+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
95+ return joinToolParts(header, body)
96+}
97+
98+// -----------------------------------------------------------------------------
99+// Job Output Tool
100+// -----------------------------------------------------------------------------
101+
102+// JobOutputToolMessageItem is a message item for job_output tool calls.
103+type JobOutputToolMessageItem struct {
104+ *baseToolMessageItem
105+}
106+
107+var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil)
108+
109+// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem].
110+func NewJobOutputToolMessageItem(
111+ sty *styles.Styles,
112+ toolCall message.ToolCall,
113+ result *message.ToolResult,
114+ canceled bool,
115+) ToolMessageItem {
116+ return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled)
117+}
118+
119+// JobOutputToolRenderContext renders job_output tool messages.
120+type JobOutputToolRenderContext struct{}
121+
122+// RenderTool implements the [ToolRenderer] interface.
123+func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
124+ cappedWidth := cappedMessageWidth(width)
125+ if !opts.ToolCall.Finished && !opts.Canceled {
126+ return pendingTool(sty, "Job", opts.Anim)
127+ }
128+
129+ var params tools.JobOutputParams
130+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
131+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
132+ }
133+
134+ var description string
135+ if opts.Result != nil && opts.Result.Metadata != "" {
136+ var meta tools.JobOutputResponseMetadata
137+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
138+ description = cmp.Or(meta.Description, meta.Command)
139+ }
140+ }
141+
142+ content := ""
143+ if opts.Result != nil {
144+ content = opts.Result.Content
145+ }
146+ return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content)
147+}
148+
149+// -----------------------------------------------------------------------------
150+// Job Kill Tool
151+// -----------------------------------------------------------------------------
152+
153+// JobKillToolMessageItem is a message item for job_kill tool calls.
154+type JobKillToolMessageItem struct {
155+ *baseToolMessageItem
156+}
104157
105− output = sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
158+var _ ToolMessageItem = (*JobKillToolMessageItem)(nil)
159+
160+// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem].
161+func NewJobKillToolMessageItem(
162+ sty *styles.Styles,
163+ toolCall message.ToolCall,
164+ result *message.ToolResult,
165+ canceled bool,
166+) ToolMessageItem {
167+ return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled)
168+}
169+
170+// JobKillToolRenderContext renders job_kill tool messages.
171+type JobKillToolRenderContext struct{}
172+
173+// RenderTool implements the [ToolRenderer] interface.
174+func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
175+ cappedWidth := cappedMessageWidth(width)
176+ if !opts.ToolCall.Finished && !opts.Canceled {
177+ return pendingTool(sty, "Job", opts.Anim)
178+ }
179+
180+ var params tools.JobKillParams
181+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
182+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
183+ }
184+
185+ var description string
186+ if opts.Result != nil && opts.Result.Metadata != "" {
187+ var meta tools.JobKillResponseMetadata
188+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
189+ description = cmp.Or(meta.Description, meta.Command)
190+ }
191+ }
192+
193+ content := ""
194+ if opts.Result != nil {
195+ content = opts.Result.Content
196+ }
197+ return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content)
198+}
199+
200+// renderJobTool renders a job-related tool with the common pattern:
201+// header → nested check → early state → body.
202+func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
203+ header := jobHeader(sty, opts.Status(), action, shellID, description, width)
204+ if opts.Nested {
205+ return header
206+ }
207+
208+ if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
209+ return joinToolParts(header, earlyState)
210+ }
211+
212+ if content == "" {
213+ return header
214+ }
215+
216+ bodyWidth := width - toolBodyLeftPaddingTotal
217+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.Expanded))
218+ return joinToolParts(header, body)
219+}
220+
221+// jobHeader builds a header for job-related tools.
222+// Format: "● Job (Action) PID shellID description..."
223+func jobHeader(sty *styles.Styles, status ToolStatus, action, shellID, description string, width int) string {
224+ icon := toolIcon(sty, status)
225+ jobPart := sty.Tool.JobToolName.Render("Job")
226+ actionPart := sty.Tool.JobAction.Render("(" + action + ")")
227+ pidPart := sty.Tool.JobPID.Render("PID " + shellID)
228+
229+ prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart)
230+
231+ if description == "" {
232+ return prefix
233+ }
234+
235+ prefixWidth := lipgloss.Width(prefix)
236+ availableWidth := width - prefixWidth - 1
237+ if availableWidth < 10 {
238+ return prefix
239+ }
240+
241+ truncatedDesc := ansi.Truncate(description, availableWidth, "…")
242+ return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc)
243+}
106244
107− return strings.Join([]string{header, "", output}, "\n")
245+// joinToolParts joins header and body with a blank line separator.
246+func joinToolParts(header, body string) string {
247+ return strings.Join([]string{header, "", body}, "\n")
108248}
internal/ui/chat/tools.go+4
@@ -158,6 +158,10 @@ func NewToolMessageItem(
158158 switch toolCall.Name {
159159 case tools.BashToolName:
160160 return NewBashToolMessageItem(sty, toolCall, result, canceled)
161+ case tools.JobOutputToolName:
162+ return NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
163+ case tools.JobKillToolName:
164+ return NewJobKillToolMessageItem(sty, toolCall, result, canceled)
161165 default:
162166 // TODO: Implement other tool items
163167 return newBaseToolMessageItem(
internal/ui/dialog/models_list.go+3−3
@@ -47,7 +47,7 @@ func (f *ModelsList) SetGroups(groups ...ModelGroup) {
4747 // Add a space separator after each provider section
4848 items = append(items, list.NewSpacerItem(1))
4949 }
50− f.List.SetItems(items...)
50+ f.SetItems(items...)
5151}
5252
5353// SetFilter sets the filter query and updates the list items.
@@ -66,7 +66,7 @@ func (f *ModelsList) SetSelectedItem(itemID string) {
6666 for _, g := range f.groups {
6767 for _, item := range g.Items {
6868 if item.ID() == itemID {
69− f.List.SetSelected(count)
69+ f.SetSelected(count)
7070 return
7171 }
7272 count++
@@ -142,7 +142,7 @@ func (f *ModelsList) VisibleItems() []list.Item {
142142
143143// Render renders the filterable list.
144144func (f *ModelsList) Render() string {
145− f.List.SetItems(f.VisibleItems()...)
145+ f.SetItems(f.VisibleItems()...)
146146 return f.List.Render()
147147}
148148
internal/ui/styles/styles.go+5−2
@@ -160,6 +160,7 @@ type Styles struct {
160160 White color.Color
161161 BlueLight color.Color
162162 Blue color.Color
163+ BlueDark color.Color
163164 Green color.Color
164165 GreenDark color.Color
165166 Red color.Color
@@ -382,6 +383,7 @@ func DefaultStyles() Styles {
382383
383384 blueLight = charmtone.Sardine
384385 blue = charmtone.Malibu
386+ blueDark = charmtone.Damson
385387
386388 // yellow = charmtone.Mustard
387389 yellow = charmtone.Mustard
@@ -424,6 +426,7 @@ func DefaultStyles() Styles {
424426 s.White = white
425427 s.BlueLight = blueLight
426428 s.Blue = blue
429+ s.BlueDark = blueDark
427430 s.Green = green
428431 s.GreenDark = greenDark
429432 s.Red = red
@@ -992,8 +995,8 @@ func DefaultStyles() Styles {
992995 s.Tool.JobIconError = base.Foreground(redDark)
993996 s.Tool.JobIconSuccess = base.Foreground(green)
994997 s.Tool.JobToolName = base.Foreground(blue)
995− s.Tool.JobAction = base.Foreground(fgHalfMuted)
996− s.Tool.JobPID = s.Subtle
998+ s.Tool.JobAction = base.Foreground(blueDark)
999+ s.Tool.JobPID = s.Muted
9971000 s.Tool.JobDescription = s.Subtle
9981001
9991002 // Agent task styles