bash.go

  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), &params); 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), &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.
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), &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.
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}