Merge pull request #1676 from charmbracelet/chat-bash-tools

Ayman Bagabas created

refactor(chat): add bash related tools

Change summary

internal/ui/AGENTS.md             |  62 +++++++-
internal/ui/chat/bash.go          | 222 ++++++++++++++++++++++++++------
internal/ui/chat/messages.go      |   6 
internal/ui/chat/tools.go         |   4 
internal/ui/dialog/models_list.go |   6 
internal/ui/list/list.go          |  25 +++
internal/ui/model/chat.go         |  24 +++
internal/ui/model/ui.go           |  12 +
internal/ui/styles/styles.go      |   7 
9 files changed, 305 insertions(+), 63 deletions(-)

Detailed changes

internal/ui/AGENTS.md 🔗

@@ -1,17 +1,59 @@
 # UI Development Instructions
 
-## General guideline
-- Never use commands to send messages when you can directly mutate children or state
-- Keep things simple do not overcomplicated
-- Create files if needed to separate logic do not nest models
+## General Guidelines
+- Never use commands to send messages when you can directly mutate children or state.
+- Keep things simple; do not overcomplicate.
+- Create files if needed to separate logic; do not nest models.
 
-## Big model
-Keep most of the logic and state in the main model `internal/ui/model/ui.go`.
+## Architecture
 
+### Main Model (`model/ui.go`)
+Keep most of the logic and state in the main model. This is where:
+- Message routing happens
+- Focus and UI state is managed
+- Layout calculations are performed
+- Dialogs are orchestrated
 
-## When working on components
-Whenever you work on components make them dumb they should not handle bubble tea messages they should have methods.
+### Components Should Be Dumb
+Components should not handle bubbletea messages directly. Instead:
+- Expose methods for state changes
+- Return `tea.Cmd` from methods when side effects are needed
+- Handle their own rendering via `Render(width int) string`
 
-## When adding logic that has to do with the chat
-Most of the logic with the chat should be in the chat component `internal/ui/model/chat.go`, keep individual items dumb and handle logic in this component.
+### Chat Logic (`model/chat.go`)
+Most chat-related logic belongs here. Individual chat items in `chat/` should be simple renderers that cache their output and invalidate when data changes (see `cachedMessageItem` in `chat/messages.go`).
 
+## Key Patterns
+
+### Composition Over Inheritance
+Use struct embedding for shared behaviors. See `chat/messages.go` for examples of reusable embedded structs for highlighting, caching, and focus.
+
+### Interfaces
+- List item interfaces are in `list/item.go`
+- Chat message interfaces are in `chat/messages.go`
+- Dialog interface is in `dialog/dialog.go`
+
+### Styling
+- All styles are defined in `styles/styles.go`
+- Access styles via `*common.Common` passed to components
+- Use semantic color fields rather than hardcoded colors
+
+### Dialogs
+- Implement the dialog interface in `dialog/dialog.go`
+- Return message types from `Update()` to signal actions to the main model
+- Use the overlay system for managing dialog lifecycle
+
+## File Organization
+- `model/` - Main UI model and major components (chat, sidebar, etc.)
+- `chat/` - Chat message item types and renderers
+- `dialog/` - Dialog implementations
+- `list/` - Generic list component with lazy rendering
+- `common/` - Shared utilities and the Common struct
+- `styles/` - All style definitions
+- `anim/` - Animation system
+- `logo/` - Logo rendering
+
+## Common Gotchas
+- Always account for padding/borders in width calculations
+- Use `tea.Batch()` when returning multiple commands
+- Pass `*common.Common` to components that need styles or app access

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/messages.go 🔗

@@ -161,7 +161,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
 		return []MessageItem{NewUserMessageItem(sty, msg)}
 	case message.Assistant:
 		var items []MessageItem
-		if shouldRenderAssistantMessage(msg) {
+		if ShouldRenderAssistantMessage(msg) {
 			items = append(items, NewAssistantMessageItem(sty, msg))
 		}
 		for _, tc := range msg.ToolCalls() {
@@ -181,11 +181,11 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
 	return []MessageItem{}
 }
 
-// shouldRenderAssistantMessage determines if an assistant message should be rendered
+// ShouldRenderAssistantMessage determines if an assistant message should be rendered
 //
 // In some cases the assistant message only has tools so we do not want to render an
 // empty message.
-func shouldRenderAssistantMessage(msg *message.Message) bool {
+func ShouldRenderAssistantMessage(msg *message.Message) bool {
 	content := strings.TrimSpace(msg.Content().Text)
 	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
 	isError := msg.FinishReason() == message.FinishReasonError

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/list/list.go 🔗

@@ -304,6 +304,31 @@ func (l *List) AppendItems(items ...Item) {
 	l.items = append(l.items, items...)
 }
 
+// RemoveItem removes the item at the given index from the list.
+func (l *List) RemoveItem(idx int) {
+	if idx < 0 || idx >= len(l.items) {
+		return
+	}
+
+	// Remove the item
+	l.items = append(l.items[:idx], l.items[idx+1:]...)
+
+	// Adjust selection if needed
+	if l.selectedIdx == idx {
+		l.selectedIdx = -1
+	} else if l.selectedIdx > idx {
+		l.selectedIdx--
+	}
+
+	// Adjust offset if needed
+	if l.offsetIdx > idx {
+		l.offsetIdx--
+	} else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
+		l.offsetIdx = max(0, len(l.items)-1)
+		l.offsetLine = 0
+	}
+}
+
 // Focus sets the focus state of the list.
 func (l *List) Focus() {
 	l.focused = true

internal/ui/model/chat.go 🔗

@@ -245,6 +245,30 @@ func (m *Chat) ClearMessages() {
 	m.ClearMouse()
 }
 
+// RemoveMessage removes a message from the chat list by its ID.
+func (m *Chat) RemoveMessage(id string) {
+	idx, ok := m.idInxMap[id]
+	if !ok {
+		return
+	}
+
+	// Remove from list
+	m.list.RemoveItem(idx)
+
+	// Remove from index map
+	delete(m.idInxMap, id)
+
+	// Rebuild index map for all items after the removed one
+	for i := idx; i < m.list.Len(); i++ {
+		if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
+			m.idInxMap[item.ID()] = i
+		}
+	}
+
+	// Clean up any paused animations for this message
+	delete(m.pausedAnimations, id)
+}
+
 // MessageItem returns the message item with the given ID, or nil if not found.
 func (m *Chat) MessageItem(id string) chat.MessageItem {
 	idx, ok := m.idInxMap[id]

internal/ui/model/ui.go 🔗

@@ -431,12 +431,16 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 	var cmds []tea.Cmd
 	existingItem := m.chat.MessageItem(msg.ID)
-	if existingItem == nil || msg.Role != message.Assistant {
-		return nil
+
+	if existingItem != nil {
+		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
+			assistantItem.SetMessage(&msg)
+		}
 	}
 
-	if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
-		assistantItem.SetMessage(&msg)
+	// if the message of the assistant does not have any  response just tool calls we need to remove it
+	if !chat.ShouldRenderAssistantMessage(&msg) && len(msg.ToolCalls()) > 0 && existingItem != nil {
+		m.chat.RemoveMessage(msg.ID)
 	}
 
 	var items []chat.MessageItem

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