1package chat
2
3import (
4 "cmp"
5 "encoding/json"
6 "fmt"
7 "strings"
8
9 "charm.land/lipgloss/v2"
10 "github.com/charmbracelet/crush/internal/agent/tools"
11 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/ui/styles"
13 "github.com/charmbracelet/x/ansi"
14)
15
16// -----------------------------------------------------------------------------
17// Bash Tool
18// -----------------------------------------------------------------------------
19
20// BashToolMessageItem is a message item that represents a bash tool call.
21type BashToolMessageItem struct {
22 *baseToolMessageItem
23}
24
25var _ ToolMessageItem = (*BashToolMessageItem)(nil)
26
27// NewBashToolMessageItem creates a new [BashToolMessageItem].
28func NewBashToolMessageItem(
29 sty *styles.Styles,
30 toolCall message.ToolCall,
31 result *message.ToolResult,
32 canceled bool,
33) ToolMessageItem {
34 return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled)
35}
36
37// BashToolRenderContext renders bash tool messages.
38type BashToolRenderContext struct{}
39
40// RenderTool implements the [ToolRenderer] interface.
41func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
42 cappedWidth := cappedMessageWidth(width)
43 if !opts.ToolCall.Finished && !opts.Canceled {
44 return pendingTool(sty, "Bash", opts.Anim)
45 }
46
47 var params tools.BashParams
48 if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil {
49 params.Command = "failed to parse command"
50 }
51
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 }
57
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)
62 }
63
64 // Regular bash command.
65 cmd := strings.ReplaceAll(params.Command, "\n", " ")
66 cmd = strings.ReplaceAll(cmd, "\t", " ")
67 toolParams := []string{cmd}
68 if params.RunInBackground {
69 toolParams = append(toolParams, "background", "true")
70 }
71
72 header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Nested, toolParams...)
73 if opts.Nested {
74 return header
75 }
76
77 if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
78 return joinToolParts(header, earlyState)
79 }
80
81 if opts.Result == nil {
82 return header
83 }
84
85 output := meta.Output
86 if output == "" && opts.Result.Content != tools.BashNoOutput {
87 output = opts.Result.Content
88 }
89 if output == "" {
90 return header
91 }
92
93 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.
103type JobOutputToolMessageItem struct {
104 *baseToolMessageItem
105}
106
107var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil)
108
109// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem].
110func 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.
120type JobOutputToolRenderContext struct{}
121
122// RenderTool implements the [ToolRenderer] interface.
123func (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), ¶ms); 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.
154type JobKillToolMessageItem struct {
155 *baseToolMessageItem
156}
157
158var _ ToolMessageItem = (*JobKillToolMessageItem)(nil)
159
160// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem].
161func 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.
171type JobKillToolRenderContext struct{}
172
173// RenderTool implements the [ToolRenderer] interface.
174func (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), ¶ms); 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.
202func 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..."
223func 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}
244
245// joinToolParts joins header and body with a blank line separator.
246func joinToolParts(header, body string) string {
247 return strings.Join([]string{header, "", body}, "\n")
248}