diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index c57d8e8d8a3ba0f567373a81707b6fdc54166fa6..19a8d7edb4898a1861ed5045f3fb4979b8e98325 100644 --- a/internal/ui/chat/bash.go +++ b/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), ¶ms) - - 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), ¶ms); 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), ¶ms); 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), ¶ms); 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") } diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 705462e2c10c049bccb7c2237e98b20a8f03477e..fb2f260e6b74493c0b7a6168ef219ecfee40176c 100644 --- a/internal/ui/chat/tools.go +++ b/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( diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index c5707f65412c7fd2d5932377d07ab5b4d42467a3..92105d717be7323e745373b59ee205b2b13f7267 100644 --- a/internal/ui/dialog/models_list.go +++ b/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() } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index a91e3810142e259dfd38ad7827e9577699a112ae..854599184d50535afedfce472a3329b410ab52d1 100644 --- a/internal/ui/styles/styles.go +++ b/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