@@ -1,14 +1,22 @@
1package chat
1package chat
4 "encoding/json"
5 "encoding/json"
9+ "charm.land/lipgloss/v2"
7 "github.com/charmbracelet/crush/internal/agent/tools"
10 "github.com/charmbracelet/crush/internal/agent/tools"
8 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/message"
9 "github.com/charmbracelet/crush/internal/ui/styles"
12 "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/x/ansi"
16+// -----------------------------------------------------------------------------
18+// -----------------------------------------------------------------------------
12// BashToolMessageItem is a message item that represents a bash tool call.
20// BashToolMessageItem is a message item that represents a bash tool call.
13type BashToolMessageItem struct {
21type BashToolMessageItem struct {
14 *baseToolMessageItem
22 *baseToolMessageItem
@@ -23,86 +31,218 @@ func NewBashToolMessageItem(
23 result *message.ToolResult,
31 result *message.ToolResult,
24 canceled bool,
32 canceled bool,
25) ToolMessageItem {
33) ToolMessageItem {
26− return newBaseToolMessageItem(
34+ return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled)
30− &BashToolRenderContext{},
35−// BashToolRenderContext holds context for rendering bash tool messages.
37+// BashToolRenderContext renders bash tool messages.
37−// It implements the [ToolRenderer] interface.
38type BashToolRenderContext struct{}
38type BashToolRenderContext struct{}
40// RenderTool implements the [ToolRenderer] interface.
40// RenderTool implements the [ToolRenderer] interface.
41func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
41func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
42 cappedWidth := cappedMessageWidth(width)
42 cappedWidth := cappedMessageWidth(width)
43− const toolName = "Bash"
44 if !opts.ToolCall.Finished && !opts.Canceled {
43 if !opts.ToolCall.Finished && !opts.Canceled {
45− return pendingTool(sty, toolName, opts.Anim)
44+ return pendingTool(sty, "Bash", opts.Anim)
48 var params tools.BashParams
47 var params tools.BashParams
50− err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms)
48+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil {
53− cmd = "failed to parse command"
49+ params.Command = "failed to parse command"
55− cmd = strings.ReplaceAll(params.Command, "\n", " ")
56− cmd = strings.ReplaceAll(cmd, "\t", " ")
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)
61− toolParams := []string{
58+ if meta.Background {
62− cmd,
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)
64+ // Regular bash command.
65+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
66+ cmd = strings.ReplaceAll(cmd, "\t", " ")
67+ toolParams := []string{cmd}
65 if params.RunInBackground {
68 if params.RunInBackground {
66 toolParams = append(toolParams, "background", "true")
69 toolParams = append(toolParams, "background", "true")
69 header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
72 header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
71 if opts.Nested {
73 if opts.Nested {
72 return header
74 return header
75− earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth)
77+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
76−
78+ return joinToolParts(header, earlyState)
77− // If this is OK that means that the tool is not done yet or it was canceled
79− return strings.Join([]string{header, "", earlyStateContent}, "\n")
82 if opts.Result == nil {
81 if opts.Result == nil {
83− // We should not get here!
84 return header
82 return header
87− var meta tools.BashResponseMetadata
88− err = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
92− output = "failed to parse output"
94− output = meta.Output
85+ output := meta.Output
95 if output == "" && opts.Result.Content != tools.BashNoOutput {
86 if output == "" && opts.Result.Content != tools.BashNoOutput {
96 output = opts.Result.Content
87 output = opts.Result.Content
99 if output == "" {
89 if output == "" {
100 return header
90 return header
103 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
93 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
94+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
95+ return joinToolParts(header, body)
98+// -----------------------------------------------------------------------------
100+// -----------------------------------------------------------------------------
102+// JobOutputToolMessageItem is a message item for job_output tool calls.
103+type JobOutputToolMessageItem struct {
104+ *baseToolMessageItem
107+var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil)
109+// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem].
110+func NewJobOutputToolMessageItem(
112+ toolCall message.ToolCall,
113+ result *message.ToolResult,
116+ return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled)
119+// JobOutputToolRenderContext renders job_output tool messages.
120+type JobOutputToolRenderContext struct{}
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)
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)
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)
143+ if opts.Result != nil {
144+ content = opts.Result.Content
146+ return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content)
149+// -----------------------------------------------------------------------------
151+// -----------------------------------------------------------------------------
153+// JobKillToolMessageItem is a message item for job_kill tool calls.
154+type JobKillToolMessageItem struct {
155+ *baseToolMessageItem
158+var _ ToolMessageItem = (*JobKillToolMessageItem)(nil)
160+// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem].
161+func NewJobKillToolMessageItem(
163+ toolCall message.ToolCall,
164+ result *message.ToolResult,
167+ return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled)
170+// JobKillToolRenderContext renders job_kill tool messages.
171+type JobKillToolRenderContext struct{}
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)
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)
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)
194+ if opts.Result != nil {
195+ content = opts.Result.Content
197+ return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content)
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)
208+ if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
209+ return joinToolParts(header, earlyState)
216+ bodyWidth := width - toolBodyLeftPaddingTotal
105− output = sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
217+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.Expanded))
218+ return joinToolParts(header, body)
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)
229+ prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart)
231+ if description == "" {
235+ prefixWidth := lipgloss.Width(prefix)
236+ availableWidth := width - prefixWidth - 1
237+ if availableWidth < 10 {
241+ truncatedDesc := ansi.Truncate(description, availableWidth, "…")
242+ return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc)
245+// joinToolParts joins header and body with a blank line separator.
246+func joinToolParts(header, body string) string {
107− return strings.Join([]string{header, "", output}, "\n")
247+ return strings.Join([]string{header, "", body}, "\n")