bash.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. package chat
  2. import (
  3. "cmp"
  4. "encoding/json"
  5. "fmt"
  6. "strings"
  7. "charm.land/lipgloss/v2"
  8. "github.com/charmbracelet/crush/internal/agent/tools"
  9. "github.com/charmbracelet/crush/internal/message"
  10. "github.com/charmbracelet/crush/internal/ui/styles"
  11. "github.com/charmbracelet/x/ansi"
  12. )
  13. // -----------------------------------------------------------------------------
  14. // Bash Tool
  15. // -----------------------------------------------------------------------------
  16. // BashToolMessageItem is a message item that represents a bash tool call.
  17. type BashToolMessageItem struct {
  18. *baseToolMessageItem
  19. }
  20. var _ ToolMessageItem = (*BashToolMessageItem)(nil)
  21. // NewBashToolMessageItem creates a new [BashToolMessageItem].
  22. func NewBashToolMessageItem(
  23. sty *styles.Styles,
  24. toolCall message.ToolCall,
  25. result *message.ToolResult,
  26. canceled bool,
  27. ) ToolMessageItem {
  28. return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled)
  29. }
  30. // BashToolRenderContext renders bash tool messages.
  31. type BashToolRenderContext struct{}
  32. // RenderTool implements the [ToolRenderer] interface.
  33. func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
  34. cappedWidth := cappedMessageWidth(width)
  35. if opts.IsPending() {
  36. return pendingTool(sty, "Bash", opts.Anim)
  37. }
  38. var params tools.BashParams
  39. if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
  40. params.Command = "failed to parse command"
  41. }
  42. // Check if this is a background job.
  43. var meta tools.BashResponseMetadata
  44. if opts.HasResult() {
  45. _ = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
  46. }
  47. if meta.Background {
  48. description := cmp.Or(meta.Description, params.Command)
  49. content := "Command: " + params.Command + "\n" + opts.Result.Content
  50. return renderJobTool(sty, opts, cappedWidth, "Start", meta.ShellID, description, content)
  51. }
  52. // Regular bash command.
  53. cmd := strings.ReplaceAll(params.Command, "\n", " ")
  54. cmd = strings.ReplaceAll(cmd, "\t", " ")
  55. toolParams := []string{cmd}
  56. if params.RunInBackground {
  57. toolParams = append(toolParams, "background", "true")
  58. }
  59. header := toolHeader(sty, opts.Status, "Bash", cappedWidth, opts.Compact, toolParams...)
  60. if opts.Compact {
  61. return header
  62. }
  63. if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
  64. return joinToolParts(header, earlyState)
  65. }
  66. if !opts.HasResult() {
  67. return header
  68. }
  69. output := meta.Output
  70. if output == "" && opts.Result.Content != tools.BashNoOutput {
  71. output = opts.Result.Content
  72. }
  73. if output == "" {
  74. return header
  75. }
  76. bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
  77. body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.ExpandedContent))
  78. return joinToolParts(header, body)
  79. }
  80. // -----------------------------------------------------------------------------
  81. // Job Output Tool
  82. // -----------------------------------------------------------------------------
  83. // JobOutputToolMessageItem is a message item for job_output tool calls.
  84. type JobOutputToolMessageItem struct {
  85. *baseToolMessageItem
  86. }
  87. var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil)
  88. // NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem].
  89. func NewJobOutputToolMessageItem(
  90. sty *styles.Styles,
  91. toolCall message.ToolCall,
  92. result *message.ToolResult,
  93. canceled bool,
  94. ) ToolMessageItem {
  95. return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled)
  96. }
  97. // JobOutputToolRenderContext renders job_output tool messages.
  98. type JobOutputToolRenderContext struct{}
  99. // RenderTool implements the [ToolRenderer] interface.
  100. func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
  101. cappedWidth := cappedMessageWidth(width)
  102. if opts.IsPending() {
  103. return pendingTool(sty, "Job", opts.Anim)
  104. }
  105. var params tools.JobOutputParams
  106. if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
  107. return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
  108. }
  109. var description string
  110. if opts.HasResult() && opts.Result.Metadata != "" {
  111. var meta tools.JobOutputResponseMetadata
  112. if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
  113. description = cmp.Or(meta.Description, meta.Command)
  114. }
  115. }
  116. content := ""
  117. if opts.HasResult() {
  118. content = opts.Result.Content
  119. }
  120. return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content)
  121. }
  122. // -----------------------------------------------------------------------------
  123. // Job Kill Tool
  124. // -----------------------------------------------------------------------------
  125. // JobKillToolMessageItem is a message item for job_kill tool calls.
  126. type JobKillToolMessageItem struct {
  127. *baseToolMessageItem
  128. }
  129. var _ ToolMessageItem = (*JobKillToolMessageItem)(nil)
  130. // NewJobKillToolMessageItem creates a new [JobKillToolMessageItem].
  131. func NewJobKillToolMessageItem(
  132. sty *styles.Styles,
  133. toolCall message.ToolCall,
  134. result *message.ToolResult,
  135. canceled bool,
  136. ) ToolMessageItem {
  137. return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled)
  138. }
  139. // JobKillToolRenderContext renders job_kill tool messages.
  140. type JobKillToolRenderContext struct{}
  141. // RenderTool implements the [ToolRenderer] interface.
  142. func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
  143. cappedWidth := cappedMessageWidth(width)
  144. if opts.IsPending() {
  145. return pendingTool(sty, "Job", opts.Anim)
  146. }
  147. var params tools.JobKillParams
  148. if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
  149. return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
  150. }
  151. var description string
  152. if opts.HasResult() && opts.Result.Metadata != "" {
  153. var meta tools.JobKillResponseMetadata
  154. if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
  155. description = cmp.Or(meta.Description, meta.Command)
  156. }
  157. }
  158. content := ""
  159. if opts.HasResult() {
  160. content = opts.Result.Content
  161. }
  162. return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content)
  163. }
  164. // renderJobTool renders a job-related tool with the common pattern:
  165. // header → nested check → early state → body.
  166. func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
  167. header := jobHeader(sty, opts.Status, action, shellID, description, width)
  168. if opts.Compact {
  169. return header
  170. }
  171. if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
  172. return joinToolParts(header, earlyState)
  173. }
  174. if content == "" {
  175. return header
  176. }
  177. bodyWidth := width - toolBodyLeftPaddingTotal
  178. body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.ExpandedContent))
  179. return joinToolParts(header, body)
  180. }
  181. // jobHeader builds a header for job-related tools.
  182. // Format: "● Job (Action) PID shellID description..."
  183. func jobHeader(sty *styles.Styles, status ToolStatus, action, shellID, description string, width int) string {
  184. icon := toolIcon(sty, status)
  185. jobPart := sty.Tool.JobToolName.Render("Job")
  186. actionPart := sty.Tool.JobAction.Render("(" + action + ")")
  187. pidPart := sty.Tool.JobPID.Render("PID " + shellID)
  188. prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart)
  189. if description == "" {
  190. return prefix
  191. }
  192. prefixWidth := lipgloss.Width(prefix)
  193. availableWidth := width - prefixWidth - 1
  194. if availableWidth < 10 {
  195. return prefix
  196. }
  197. truncatedDesc := ansi.Truncate(description, availableWidth, "…")
  198. return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc)
  199. }
  200. // joinToolParts joins header and body with a blank line separator.
  201. func joinToolParts(header, body string) string {
  202. return strings.Join([]string{header, "", body}, "\n")
  203. }