refactor(chat): add bash related tools

Kujtim Hoxha created

Change summary

internal/ui/chat/bash.go          | 222 ++++++++++++++++++++++++++------
internal/ui/chat/tools.go         |   4 
internal/ui/dialog/models_list.go |   6 
internal/ui/styles/styles.go      |   7 
4 files changed, 193 insertions(+), 46 deletions(-)

Detailed changes

internal/ui/chat/bash.go 🔗

@@ -1,14 +1,22 @@
 package chat
 
 import (
+	"cmp"
 	"encoding/json"
+	"fmt"
 	"strings"
 
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
 )
 
+// -----------------------------------------------------------------------------
+// Bash Tool
+// -----------------------------------------------------------------------------
+
 // BashToolMessageItem is a message item that represents a bash tool call.
 type BashToolMessageItem struct {
 	*baseToolMessageItem
@@ -23,86 +31,218 @@ func NewBashToolMessageItem(
 	result *message.ToolResult,
 	canceled bool,
 ) ToolMessageItem {
-	return newBaseToolMessageItem(
-		sty,
-		toolCall,
-		result,
-		&BashToolRenderContext{},
-		canceled,
-	)
+	return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled)
 }
 
-// BashToolRenderContext holds context for rendering bash tool messages.
-//
-// It implements the [ToolRenderer] interface.
+// BashToolRenderContext renders bash tool messages.
 type BashToolRenderContext struct{}
 
 // RenderTool implements the [ToolRenderer] interface.
 func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	const toolName = "Bash"
 	if !opts.ToolCall.Finished && !opts.Canceled {
-		return pendingTool(sty, toolName, opts.Anim)
+		return pendingTool(sty, "Bash", opts.Anim)
 	}
 
 	var params tools.BashParams
-	var cmd string
-	err := json.Unmarshal([]byte(opts.ToolCall.Input), &params)
-
-	if err != nil {
-		cmd = "failed to parse command"
-	} else {
-		cmd = strings.ReplaceAll(params.Command, "\n", " ")
-		cmd = strings.ReplaceAll(cmd, "\t", "    ")
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		params.Command = "failed to parse command"
 	}
 
-	// TODO: if the tool is being run in the background use the background job renderer
+	// Check if this is a background job.
+	var meta tools.BashResponseMetadata
+	if opts.Result != nil {
+		_ = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
+	}
 
-	toolParams := []string{
-		cmd,
+	if meta.Background {
+		description := cmp.Or(meta.Description, params.Command)
+		content := "Command: " + params.Command + "\n" + opts.Result.Content
+		return renderJobTool(sty, opts, cappedWidth, "Start", meta.ShellID, description, content)
 	}
 
+	// Regular bash command.
+	cmd := strings.ReplaceAll(params.Command, "\n", " ")
+	cmd = strings.ReplaceAll(cmd, "\t", "    ")
+	toolParams := []string{cmd}
 	if params.RunInBackground {
 		toolParams = append(toolParams, "background", "true")
 	}
 
 	header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
-
 	if opts.Nested {
 		return header
 	}
 
-	earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth)
-
-	// If this is OK that means that the tool is not done yet or it was canceled
-	if ok {
-		return strings.Join([]string{header, "", earlyStateContent}, "\n")
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
 	}
 
 	if opts.Result == nil {
-		// We should not get here!
 		return header
 	}
 
-	var meta tools.BashResponseMetadata
-	err = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
-
-	var output string
-	if err != nil {
-		output = "failed to parse output"
-	}
-	output = meta.Output
+	output := meta.Output
 	if output == "" && opts.Result.Content != tools.BashNoOutput {
 		output = opts.Result.Content
 	}
-
 	if output == "" {
 		return header
 	}
 
 	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Job Output Tool
+// -----------------------------------------------------------------------------
+
+// JobOutputToolMessageItem is a message item for job_output tool calls.
+type JobOutputToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil)
+
+// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem].
+func NewJobOutputToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled)
+}
+
+// JobOutputToolRenderContext renders job_output tool messages.
+type JobOutputToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if !opts.ToolCall.Finished && !opts.Canceled {
+		return pendingTool(sty, "Job", opts.Anim)
+	}
+
+	var params tools.JobOutputParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	var description string
+	if opts.Result != nil && opts.Result.Metadata != "" {
+		var meta tools.JobOutputResponseMetadata
+		if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
+			description = cmp.Or(meta.Description, meta.Command)
+		}
+	}
+
+	content := ""
+	if opts.Result != nil {
+		content = opts.Result.Content
+	}
+	return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content)
+}
+
+// -----------------------------------------------------------------------------
+// Job Kill Tool
+// -----------------------------------------------------------------------------
+
+// JobKillToolMessageItem is a message item for job_kill tool calls.
+type JobKillToolMessageItem struct {
+	*baseToolMessageItem
+}
 
-	output = sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
+var _ ToolMessageItem = (*JobKillToolMessageItem)(nil)
+
+// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem].
+func NewJobKillToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled)
+}
+
+// JobKillToolRenderContext renders job_kill tool messages.
+type JobKillToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if !opts.ToolCall.Finished && !opts.Canceled {
+		return pendingTool(sty, "Job", opts.Anim)
+	}
+
+	var params tools.JobKillParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	var description string
+	if opts.Result != nil && opts.Result.Metadata != "" {
+		var meta tools.JobKillResponseMetadata
+		if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
+			description = cmp.Or(meta.Description, meta.Command)
+		}
+	}
+
+	content := ""
+	if opts.Result != nil {
+		content = opts.Result.Content
+	}
+	return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content)
+}
+
+// renderJobTool renders a job-related tool with the common pattern:
+// header → nested check → early state → body.
+func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
+	header := jobHeader(sty, opts.Status(), action, shellID, description, width)
+	if opts.Nested {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if content == "" {
+		return header
+	}
+
+	bodyWidth := width - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.Expanded))
+	return joinToolParts(header, body)
+}
+
+// jobHeader builds a header for job-related tools.
+// Format: "● Job (Action) PID shellID description..."
+func jobHeader(sty *styles.Styles, status ToolStatus, action, shellID, description string, width int) string {
+	icon := toolIcon(sty, status)
+	jobPart := sty.Tool.JobToolName.Render("Job")
+	actionPart := sty.Tool.JobAction.Render("(" + action + ")")
+	pidPart := sty.Tool.JobPID.Render("PID " + shellID)
+
+	prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart)
+
+	if description == "" {
+		return prefix
+	}
+
+	prefixWidth := lipgloss.Width(prefix)
+	availableWidth := width - prefixWidth - 1
+	if availableWidth < 10 {
+		return prefix
+	}
+
+	truncatedDesc := ansi.Truncate(description, availableWidth, "…")
+	return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc)
+}
 
-	return strings.Join([]string{header, "", output}, "\n")
+// joinToolParts joins header and body with a blank line separator.
+func joinToolParts(header, body string) string {
+	return strings.Join([]string{header, "", body}, "\n")
 }

internal/ui/chat/tools.go 🔗

@@ -158,6 +158,10 @@ func NewToolMessageItem(
 	switch toolCall.Name {
 	case tools.BashToolName:
 		return NewBashToolMessageItem(sty, toolCall, result, canceled)
+	case tools.JobOutputToolName:
+		return NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
+	case tools.JobKillToolName:
+		return NewJobKillToolMessageItem(sty, toolCall, result, canceled)
 	default:
 		// TODO: Implement other tool items
 		return newBaseToolMessageItem(

internal/ui/dialog/models_list.go 🔗

@@ -47,7 +47,7 @@ func (f *ModelsList) SetGroups(groups ...ModelGroup) {
 		// Add a space separator after each provider section
 		items = append(items, list.NewSpacerItem(1))
 	}
-	f.List.SetItems(items...)
+	f.SetItems(items...)
 }
 
 // SetFilter sets the filter query and updates the list items.
@@ -66,7 +66,7 @@ func (f *ModelsList) SetSelectedItem(itemID string) {
 	for _, g := range f.groups {
 		for _, item := range g.Items {
 			if item.ID() == itemID {
-				f.List.SetSelected(count)
+				f.SetSelected(count)
 				return
 			}
 			count++
@@ -142,7 +142,7 @@ func (f *ModelsList) VisibleItems() []list.Item {
 
 // Render renders the filterable list.
 func (f *ModelsList) Render() string {
-	f.List.SetItems(f.VisibleItems()...)
+	f.SetItems(f.VisibleItems()...)
 	return f.List.Render()
 }
 

internal/ui/styles/styles.go 🔗

@@ -160,6 +160,7 @@ type Styles struct {
 	White         color.Color
 	BlueLight     color.Color
 	Blue          color.Color
+	BlueDark      color.Color
 	Green         color.Color
 	GreenDark     color.Color
 	Red           color.Color
@@ -382,6 +383,7 @@ func DefaultStyles() Styles {
 
 		blueLight = charmtone.Sardine
 		blue      = charmtone.Malibu
+		blueDark  = charmtone.Damson
 
 		// yellow = charmtone.Mustard
 		yellow = charmtone.Mustard
@@ -424,6 +426,7 @@ func DefaultStyles() Styles {
 	s.White = white
 	s.BlueLight = blueLight
 	s.Blue = blue
+	s.BlueDark = blueDark
 	s.Green = green
 	s.GreenDark = greenDark
 	s.Red = red
@@ -992,8 +995,8 @@ func DefaultStyles() Styles {
 	s.Tool.JobIconError = base.Foreground(redDark)
 	s.Tool.JobIconSuccess = base.Foreground(green)
 	s.Tool.JobToolName = base.Foreground(blue)
-	s.Tool.JobAction = base.Foreground(fgHalfMuted)
-	s.Tool.JobPID = s.Subtle
+	s.Tool.JobAction = base.Foreground(blueDark)
+	s.Tool.JobPID = s.Muted
 	s.Tool.JobDescription = s.Subtle
 
 	// Agent task styles