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	if opts.IsPending() {
 43		return pendingTool(sty, "Bash", opts.Anim)
 44	}
 45
 46	var params tools.BashParams
 47	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
 48		params.Command = "failed to parse command"
 49	}
 50
 51	// Check if this is a background job.
 52	var meta tools.BashResponseMetadata
 53	if opts.HasResult() {
 54		_ = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
 55	}
 56
 57	if meta.Background {
 58		description := cmp.Or(meta.Description, params.Command)
 59		content := "Command: " + params.Command + "\n" + opts.Result.Content
 60		return renderJobTool(sty, opts, width, "Start", meta.ShellID, description, content)
 61	}
 62
 63	// Regular bash command.
 64	cmd := strings.ReplaceAll(params.Command, "\n", " ")
 65	cmd = strings.ReplaceAll(cmd, "\t", "    ")
 66	toolParams := []string{cmd}
 67	if params.RunInBackground {
 68		toolParams = append(toolParams, "background", "true")
 69	}
 70
 71	header := toolHeader(sty, opts.Status, "Bash", width, opts.Compact, toolParams...)
 72	if opts.Compact {
 73		return header
 74	}
 75
 76	if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
 77		return joinToolParts(header, earlyState)
 78	}
 79
 80	if !opts.HasResult() {
 81		return header
 82	}
 83
 84	output := meta.Output
 85	if output == "" && opts.Result.Content != tools.BashNoOutput {
 86		output = opts.Result.Content
 87	}
 88	if output == "" {
 89		return header
 90	}
 91
 92	bodyWidth := width - toolBodyLeftPaddingTotal
 93	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.ExpandedContent))
 94	return joinToolParts(header, body)
 95}
 96
 97// -----------------------------------------------------------------------------
 98// Job Output Tool
 99// -----------------------------------------------------------------------------
100
101// JobOutputToolMessageItem is a message item for job_output tool calls.
102type JobOutputToolMessageItem struct {
103	*baseToolMessageItem
104}
105
106var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil)
107
108// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem].
109func NewJobOutputToolMessageItem(
110	sty *styles.Styles,
111	toolCall message.ToolCall,
112	result *message.ToolResult,
113	canceled bool,
114) ToolMessageItem {
115	return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled)
116}
117
118// JobOutputToolRenderContext renders job_output tool messages.
119type JobOutputToolRenderContext struct{}
120
121// RenderTool implements the [ToolRenderer] interface.
122func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
123	if opts.IsPending() {
124		return pendingTool(sty, "Job", opts.Anim)
125	}
126
127	var params tools.JobOutputParams
128	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
129		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width)
130	}
131
132	var description string
133	if opts.HasResult() && opts.Result.Metadata != "" {
134		var meta tools.JobOutputResponseMetadata
135		if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
136			description = cmp.Or(meta.Description, meta.Command)
137		}
138	}
139
140	content := ""
141	if opts.HasResult() {
142		content = opts.Result.Content
143	}
144	return renderJobTool(sty, opts, width, "Output", params.ShellID, description, content)
145}
146
147// -----------------------------------------------------------------------------
148// Job Kill Tool
149// -----------------------------------------------------------------------------
150
151// JobKillToolMessageItem is a message item for job_kill tool calls.
152type JobKillToolMessageItem struct {
153	*baseToolMessageItem
154}
155
156var _ ToolMessageItem = (*JobKillToolMessageItem)(nil)
157
158// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem].
159func NewJobKillToolMessageItem(
160	sty *styles.Styles,
161	toolCall message.ToolCall,
162	result *message.ToolResult,
163	canceled bool,
164) ToolMessageItem {
165	return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled)
166}
167
168// JobKillToolRenderContext renders job_kill tool messages.
169type JobKillToolRenderContext struct{}
170
171// RenderTool implements the [ToolRenderer] interface.
172func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
173	if opts.IsPending() {
174		return pendingTool(sty, "Job", opts.Anim)
175	}
176
177	var params tools.JobKillParams
178	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
179		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width)
180	}
181
182	var description string
183	if opts.HasResult() && opts.Result.Metadata != "" {
184		var meta tools.JobKillResponseMetadata
185		if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
186			description = cmp.Or(meta.Description, meta.Command)
187		}
188	}
189
190	content := ""
191	if opts.HasResult() {
192		content = opts.Result.Content
193	}
194	return renderJobTool(sty, opts, width, "Kill", params.ShellID, description, content)
195}
196
197// renderJobTool renders a job-related tool with the common pattern:
198// header → nested check → early state → body.
199func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
200	header := jobHeader(sty, opts.Status, action, shellID, description, width)
201	if opts.Compact {
202		return header
203	}
204
205	if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
206		return joinToolParts(header, earlyState)
207	}
208
209	if content == "" {
210		return header
211	}
212
213	bodyWidth := width - toolBodyLeftPaddingTotal
214	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.ExpandedContent))
215	return joinToolParts(header, body)
216}
217
218// jobHeader builds a header for job-related tools.
219// Format: "● Job (Action) PID shellID description..."
220func jobHeader(sty *styles.Styles, status ToolStatus, action, shellID, description string, width int) string {
221	icon := toolIcon(sty, status)
222	jobPart := sty.Tool.JobToolName.Render("Job")
223	actionPart := sty.Tool.JobAction.Render("(" + action + ")")
224	pidPart := sty.Tool.JobPID.Render("PID " + shellID)
225
226	prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart)
227
228	if description == "" {
229		return prefix
230	}
231
232	prefixWidth := lipgloss.Width(prefix)
233	availableWidth := width - prefixWidth - 1
234	if availableWidth < 10 {
235		return prefix
236	}
237
238	truncatedDesc := ansi.Truncate(description, availableWidth, "…")
239	return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc)
240}
241
242// joinToolParts joins header and body with a blank line separator.
243func joinToolParts(header, body string) string {
244	return strings.Join([]string{header, "", body}, "\n")
245}