Detailed changes
@@ -0,0 +1,302 @@
+package chat
+
+import (
+ "encoding/json"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "charm.land/lipgloss/v2/tree"
+ "github.com/charmbracelet/crush/internal/agent"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/ui/anim"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// Agent Tool
+// -----------------------------------------------------------------------------
+
+// NestedToolContainer is an interface for tool items that can contain nested tool calls.
+type NestedToolContainer interface {
+ NestedTools() []ToolMessageItem
+ SetNestedTools(tools []ToolMessageItem)
+ AddNestedTool(tool ToolMessageItem)
+}
+
+// AgentToolMessageItem is a message item that represents an agent tool call.
+type AgentToolMessageItem struct {
+ *baseToolMessageItem
+
+ nestedTools []ToolMessageItem
+}
+
+var (
+ _ ToolMessageItem = (*AgentToolMessageItem)(nil)
+ _ NestedToolContainer = (*AgentToolMessageItem)(nil)
+)
+
+// NewAgentToolMessageItem creates a new [AgentToolMessageItem].
+func NewAgentToolMessageItem(
+ sty *styles.Styles,
+ toolCall message.ToolCall,
+ result *message.ToolResult,
+ canceled bool,
+) *AgentToolMessageItem {
+ t := &AgentToolMessageItem{}
+ t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled)
+ // For the agent tool we keep spinning until the tool call is finished.
+ t.spinningFunc = func(state SpinningState) bool {
+ return state.Result == nil && !state.Canceled
+ }
+ return t
+}
+
+// Animate progresses the message animation if it should be spinning.
+func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
+ if a.result != nil || a.canceled {
+ return nil
+ }
+ if msg.ID == a.ID() {
+ return a.anim.Animate(msg)
+ }
+ for _, nestedTool := range a.nestedTools {
+ if msg.ID != nestedTool.ID() {
+ continue
+ }
+ if s, ok := nestedTool.(Animatable); ok {
+ return s.Animate(msg)
+ }
+ }
+ return nil
+}
+
+// NestedTools returns the nested tools.
+func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem {
+ return a.nestedTools
+}
+
+// SetNestedTools sets the nested tools.
+func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
+ a.nestedTools = tools
+ a.clearCache()
+}
+
+// AddNestedTool adds a nested tool.
+func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) {
+ // Mark nested tools as simple (compact) rendering.
+ if s, ok := tool.(Compactable); ok {
+ s.SetCompact(true)
+ }
+ a.nestedTools = append(a.nestedTools, tool)
+ a.clearCache()
+}
+
+// AgentToolRenderContext renders agent tool messages.
+type AgentToolRenderContext struct {
+ agent *AgentToolMessageItem
+}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+ cappedWidth := cappedMessageWidth(width)
+ if !opts.ToolCall.Finished && !opts.Canceled && len(r.agent.nestedTools) == 0 {
+ return pendingTool(sty, "Agent", opts.Anim)
+ }
+
+ var params agent.AgentParams
+ _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms)
+
+ prompt := params.Prompt
+ prompt = strings.ReplaceAll(prompt, "\n", " ")
+
+ header := toolHeader(sty, opts.Status(), "Agent", cappedWidth, opts.Compact)
+ if opts.Compact {
+ return header
+ }
+
+ // Build the task tag and prompt.
+ taskTag := sty.Tool.AgentTaskTag.Render("Task")
+ taskTagWidth := lipgloss.Width(taskTag)
+
+ // Calculate remaining width for prompt.
+ remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing
+
+ promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
+
+ header = lipgloss.JoinVertical(
+ lipgloss.Left,
+ header,
+ "",
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ taskTag,
+ " ",
+ promptText,
+ ),
+ )
+
+ // Build tree with nested tool calls.
+ childTools := tree.Root(header)
+
+ for _, nestedTool := range r.agent.nestedTools {
+ childView := nestedTool.Render(remainingWidth)
+ childTools.Child(childView)
+ }
+
+ // Build parts.
+ var parts []string
+ parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String())
+
+ // Show animation if still running.
+ if opts.Result == nil && !opts.Canceled {
+ parts = append(parts, "", opts.Anim.Render())
+ }
+
+ result := lipgloss.JoinVertical(lipgloss.Left, parts...)
+
+ // Add body content when completed.
+ if opts.Result != nil && opts.Result.Content != "" {
+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
+ return joinToolParts(result, body)
+ }
+
+ return result
+}
+
+// -----------------------------------------------------------------------------
+// Agentic Fetch Tool
+// -----------------------------------------------------------------------------
+
+// AgenticFetchToolMessageItem is a message item that represents an agentic fetch tool call.
+type AgenticFetchToolMessageItem struct {
+ *baseToolMessageItem
+
+ nestedTools []ToolMessageItem
+}
+
+var (
+ _ ToolMessageItem = (*AgenticFetchToolMessageItem)(nil)
+ _ NestedToolContainer = (*AgenticFetchToolMessageItem)(nil)
+)
+
+// NewAgenticFetchToolMessageItem creates a new [AgenticFetchToolMessageItem].
+func NewAgenticFetchToolMessageItem(
+ sty *styles.Styles,
+ toolCall message.ToolCall,
+ result *message.ToolResult,
+ canceled bool,
+) *AgenticFetchToolMessageItem {
+ t := &AgenticFetchToolMessageItem{}
+ t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled)
+ // For the agentic fetch tool we keep spinning until the tool call is finished.
+ t.spinningFunc = func(state SpinningState) bool {
+ return state.Result == nil && !state.Canceled
+ }
+ return t
+}
+
+// NestedTools returns the nested tools.
+func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
+ return a.nestedTools
+}
+
+// SetNestedTools sets the nested tools.
+func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
+ a.nestedTools = tools
+ a.clearCache()
+}
+
+// AddNestedTool adds a nested tool.
+func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) {
+ // Mark nested tools as simple (compact) rendering.
+ if s, ok := tool.(Compactable); ok {
+ s.SetCompact(true)
+ }
+ a.nestedTools = append(a.nestedTools, tool)
+ a.clearCache()
+}
+
+// AgenticFetchToolRenderContext renders agentic fetch tool messages.
+type AgenticFetchToolRenderContext struct {
+ fetch *AgenticFetchToolMessageItem
+}
+
+// agenticFetchParams matches tools.AgenticFetchParams.
+type agenticFetchParams struct {
+ URL string `json:"url,omitempty"`
+ Prompt string `json:"prompt"`
+}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+ cappedWidth := cappedMessageWidth(width)
+ if !opts.ToolCall.Finished && !opts.Canceled && len(r.fetch.nestedTools) == 0 {
+ return pendingTool(sty, "Agentic Fetch", opts.Anim)
+ }
+
+ var params agenticFetchParams
+ _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms)
+
+ prompt := params.Prompt
+ prompt = strings.ReplaceAll(prompt, "\n", " ")
+
+ // Build header with optional URL param.
+ toolParams := []string{}
+ if params.URL != "" {
+ toolParams = append(toolParams, params.URL)
+ }
+
+ header := toolHeader(sty, opts.Status(), "Agentic Fetch", cappedWidth, opts.Compact, toolParams...)
+ if opts.Compact {
+ return header
+ }
+
+ // Build the prompt tag.
+ promptTag := sty.Tool.AgenticFetchPromptTag.Render("Prompt")
+ promptTagWidth := lipgloss.Width(promptTag)
+
+ // Calculate remaining width for prompt text.
+ remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing
+
+ promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
+
+ header = lipgloss.JoinVertical(
+ lipgloss.Left,
+ header,
+ "",
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ promptTag,
+ " ",
+ promptText,
+ ),
+ )
+
+ // Build tree with nested tool calls.
+ childTools := tree.Root(header)
+
+ for _, nestedTool := range r.fetch.nestedTools {
+ childView := nestedTool.Render(remainingWidth)
+ childTools.Child(childView)
+ }
+
+ // Build parts.
+ var parts []string
+ parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
+
+ // Show animation if still running.
+ if opts.Result == nil && !opts.Canceled {
+ parts = append(parts, "", opts.Anim.Render())
+ }
+
+ result := lipgloss.JoinVertical(lipgloss.Left, parts...)
+
+ // Add body content when completed.
+ if opts.Result != nil && opts.Result.Content != "" {
+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
+ return joinToolParts(result, body)
+ }
+
+ return result
+}
@@ -69,8 +69,8 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
toolParams = append(toolParams, "background", "true")
}
- header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Nested, toolParams...)
- if opts.Nested {
+ header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Compact, toolParams...)
+ if opts.Compact {
return header
}
@@ -91,7 +91,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
}
bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
- body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.ExpandedContent))
return joinToolParts(header, body)
}
@@ -201,7 +201,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
// 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 {
+ if opts.Compact {
return header
}
@@ -214,7 +214,7 @@ func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action,
}
bodyWidth := width - toolBodyLeftPaddingTotal
- body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.Expanded))
+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.ExpandedContent))
return joinToolParts(header, body)
}
@@ -0,0 +1,68 @@
+package chat
+
+import (
+ "encoding/json"
+
+ "github.com/charmbracelet/crush/internal/agent/tools"
+ "github.com/charmbracelet/crush/internal/fsext"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// Diagnostics Tool
+// -----------------------------------------------------------------------------
+
+// DiagnosticsToolMessageItem is a message item that represents a diagnostics tool call.
+type DiagnosticsToolMessageItem struct {
+ *baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*DiagnosticsToolMessageItem)(nil)
+
+// NewDiagnosticsToolMessageItem creates a new [DiagnosticsToolMessageItem].
+func NewDiagnosticsToolMessageItem(
+ sty *styles.Styles,
+ toolCall message.ToolCall,
+ result *message.ToolResult,
+ canceled bool,
+) ToolMessageItem {
+ return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled)
+}
+
+// DiagnosticsToolRenderContext renders diagnostics tool messages.
+type DiagnosticsToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+ cappedWidth := cappedMessageWidth(width)
+ if !opts.ToolCall.Finished && !opts.Canceled {
+ return pendingTool(sty, "Diagnostics", opts.Anim)
+ }
+
+ var params tools.DiagnosticsParams
+ _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms)
+
+ // Show "project" if no file path, otherwise show the file path.
+ mainParam := "project"
+ if params.FilePath != "" {
+ mainParam = fsext.PrettyPath(params.FilePath)
+ }
+
+ header := toolHeader(sty, opts.Status(), "Diagnostics", cappedWidth, opts.Compact, mainParam)
+ if opts.Compact {
+ return header
+ }
+
+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+ return joinToolParts(header, earlyState)
+ }
+
+ if opts.Result == nil || opts.Result.Content == "" {
+ return header
+ }
+
+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+ return joinToolParts(header, body)
+}
@@ -0,0 +1,192 @@
+package chat
+
+import (
+ "encoding/json"
+
+ "github.com/charmbracelet/crush/internal/agent/tools"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// Fetch Tool
+// -----------------------------------------------------------------------------
+
+// FetchToolMessageItem is a message item that represents a fetch tool call.
+type FetchToolMessageItem struct {
+ *baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*FetchToolMessageItem)(nil)
+
+// NewFetchToolMessageItem creates a new [FetchToolMessageItem].
+func NewFetchToolMessageItem(
+ sty *styles.Styles,
+ toolCall message.ToolCall,
+ result *message.ToolResult,
+ canceled bool,
+) ToolMessageItem {
+ return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled)
+}
+
+// FetchToolRenderContext renders fetch tool messages.
+type FetchToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+ cappedWidth := cappedMessageWidth(width)
+ if !opts.ToolCall.Finished && !opts.Canceled {
+ return pendingTool(sty, "Fetch", opts.Anim)
+ }
+
+ var params tools.FetchParams
+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil {
+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+ }
+
+ toolParams := []string{params.URL}
+ if params.Format != "" {
+ toolParams = append(toolParams, "format", params.Format)
+ }
+ if params.Timeout != 0 {
+ toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
+ }
+
+ header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...)
+ if opts.Compact {
+ return header
+ }
+
+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+ return joinToolParts(header, earlyState)
+ }
+
+ if opts.Result == nil || opts.Result.Content == "" {
+ return header
+ }
+
+ // Determine file extension for syntax highlighting based on format.
+ file := getFileExtensionForFormat(params.Format)
+ body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.ExpandedContent)
+ return joinToolParts(header, body)
+}
+
+// getFileExtensionForFormat returns a filename with appropriate extension for syntax highlighting.
+func getFileExtensionForFormat(format string) string {
+ switch format {
+ case "text":
+ return "fetch.txt"
+ case "html":
+ return "fetch.html"
+ default:
+ return "fetch.md"
+ }
+}
+
+// -----------------------------------------------------------------------------
+// WebFetch Tool
+// -----------------------------------------------------------------------------
+
+// WebFetchToolMessageItem is a message item that represents a web_fetch tool call.
+type WebFetchToolMessageItem struct {
+ *baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*WebFetchToolMessageItem)(nil)
+
+// NewWebFetchToolMessageItem creates a new [WebFetchToolMessageItem].
+func NewWebFetchToolMessageItem(
+ sty *styles.Styles,
+ toolCall message.ToolCall,
+ result *message.ToolResult,
+ canceled bool,
+) ToolMessageItem {
+ return newBaseToolMessageItem(sty, toolCall, result, &WebFetchToolRenderContext{}, canceled)
+}
+
+// WebFetchToolRenderContext renders web_fetch tool messages.
+type WebFetchToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+ cappedWidth := cappedMessageWidth(width)
+ if !opts.ToolCall.Finished && !opts.Canceled {
+ return pendingTool(sty, "Fetch", opts.Anim)
+ }
+
+ var params tools.WebFetchParams
+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil {
+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+ }
+
+ toolParams := []string{params.URL}
+ header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...)
+ if opts.Compact {
+ return header
+ }
+
+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+ return joinToolParts(header, earlyState)
+ }
+
+ if opts.Result == nil || opts.Result.Content == "" {
+ return header
+ }
+
+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent)
+ return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// WebSearch Tool
+// -----------------------------------------------------------------------------
+
+// WebSearchToolMessageItem is a message item that represents a web_search tool call.
+type WebSearchToolMessageItem struct {
+ *baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*WebSearchToolMessageItem)(nil)
+
+// NewWebSearchToolMessageItem creates a new [WebSearchToolMessageItem].
+func NewWebSearchToolMessageItem(
+ sty *styles.Styles,
+ toolCall message.ToolCall,
+ result *message.ToolResult,
+ canceled bool,
+) ToolMessageItem {
+ return newBaseToolMessageItem(sty, toolCall, result, &WebSearchToolRenderContext{}, canceled)
+}
+
+// WebSearchToolRenderContext renders web_search tool messages.
+type WebSearchToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+ cappedWidth := cappedMessageWidth(width)
+ if !opts.ToolCall.Finished && !opts.Canceled {
+ return pendingTool(sty, "Search", opts.Anim)
+ }
+
+ var params tools.WebSearchParams
+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil {
+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+ }
+
+ toolParams := []string{params.Query}
+ header := toolHeader(sty, opts.Status(), "Search", cappedWidth, opts.Compact, toolParams...)
+ if opts.Compact {
+ return header
+ }
+
+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+ return joinToolParts(header, earlyState)
+ }
+
+ if opts.Result == nil || opts.Result.Content == "" {
+ return header
+ }
+
+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent)
+ return joinToolParts(header, body)
+}
@@ -56,8 +56,8 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
}
- header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Nested, toolParams...)
- if opts.Nested {
+ header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Compact, toolParams...)
+ if opts.Compact {
return header
}
@@ -87,7 +87,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
}
// Render code content with syntax highlighting.
- body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.Expanded)
+ body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.ExpandedContent)
return joinToolParts(header, body)
}
@@ -128,8 +128,8 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
}
file := fsext.PrettyPath(params.FilePath)
- header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Nested, file)
- if opts.Nested {
+ header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Compact, file)
+ if opts.Compact {
return header
}
@@ -142,7 +142,7 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
}
// Render code content with syntax highlighting.
- body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.Expanded)
+ body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent)
return joinToolParts(header, body)
}
@@ -183,8 +183,8 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
}
file := fsext.PrettyPath(params.FilePath)
- header := toolHeader(sty, opts.Status(), "Edit", width, opts.Nested, file)
- if opts.Nested {
+ header := toolHeader(sty, opts.Status(), "Edit", width, opts.Compact, file)
+ if opts.Compact {
return header
}
@@ -200,12 +200,12 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
var meta tools.EditResponseMetadata
if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
bodyWidth := width - toolBodyLeftPaddingTotal
- body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
return joinToolParts(header, body)
}
// Render diff.
- body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.Expanded)
+ body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent)
return joinToolParts(header, body)
}
@@ -251,8 +251,8 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits)))
}
- header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Nested, toolParams...)
- if opts.Nested {
+ header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Compact, toolParams...)
+ if opts.Compact {
return header
}
@@ -268,11 +268,73 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
var meta tools.MultiEditResponseMetadata
if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
bodyWidth := width - toolBodyLeftPaddingTotal
- body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
return joinToolParts(header, body)
}
// Render diff with optional failed edits note.
- body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.Expanded)
+ body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent)
+ return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Download Tool
+// -----------------------------------------------------------------------------
+
+// DownloadToolMessageItem is a message item that represents a download tool call.
+type DownloadToolMessageItem struct {
+ *baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*DownloadToolMessageItem)(nil)
+
+// NewDownloadToolMessageItem creates a new [DownloadToolMessageItem].
+func NewDownloadToolMessageItem(
+ sty *styles.Styles,
+ toolCall message.ToolCall,
+ result *message.ToolResult,
+ canceled bool,
+) ToolMessageItem {
+ return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled)
+}
+
+// DownloadToolRenderContext renders download tool messages.
+type DownloadToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+ cappedWidth := cappedMessageWidth(width)
+ if !opts.ToolCall.Finished && !opts.Canceled {
+ return pendingTool(sty, "Download", opts.Anim)
+ }
+
+ var params tools.DownloadParams
+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil {
+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+ }
+
+ toolParams := []string{params.URL}
+ if params.FilePath != "" {
+ toolParams = append(toolParams, "file_path", fsext.PrettyPath(params.FilePath))
+ }
+ if params.Timeout != 0 {
+ toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
+ }
+
+ header := toolHeader(sty, opts.Status(), "Download", cappedWidth, opts.Compact, toolParams...)
+ if opts.Compact {
+ return header
+ }
+
+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+ return joinToolParts(header, earlyState)
+ }
+
+ if opts.Result == nil || opts.Result.Content == "" {
+ return header
+ }
+
+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
return joinToolParts(header, body)
}
@@ -171,6 +171,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
}
items = append(items, NewToolMessageItem(
sty,
+ msg.ID,
tc,
result,
msg.FinishReason() == message.FinishReasonCanceled,
@@ -50,8 +50,8 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
toolParams = append(toolParams, "path", params.Path)
}
- header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Nested, toolParams...)
- if opts.Nested {
+ header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Compact, toolParams...)
+ if opts.Compact {
return header
}
@@ -64,7 +64,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
}
bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
- body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
return joinToolParts(header, body)
}
@@ -115,8 +115,8 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
toolParams = append(toolParams, "literal", "true")
}
- header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Nested, toolParams...)
- if opts.Nested {
+ header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Compact, toolParams...)
+ if opts.Compact {
return header
}
@@ -129,7 +129,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
}
bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
- body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
return joinToolParts(header, body)
}
@@ -175,8 +175,8 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To
}
path = fsext.PrettyPath(path)
- header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Nested, path)
- if opts.Nested {
+ header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Compact, path)
+ if opts.Compact {
return header
}
@@ -189,6 +189,68 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To
}
bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
- body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+ return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Sourcegraph Tool
+// -----------------------------------------------------------------------------
+
+// SourcegraphToolMessageItem is a message item that represents a sourcegraph tool call.
+type SourcegraphToolMessageItem struct {
+ *baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*SourcegraphToolMessageItem)(nil)
+
+// NewSourcegraphToolMessageItem creates a new [SourcegraphToolMessageItem].
+func NewSourcegraphToolMessageItem(
+ sty *styles.Styles,
+ toolCall message.ToolCall,
+ result *message.ToolResult,
+ canceled bool,
+) ToolMessageItem {
+ return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled)
+}
+
+// SourcegraphToolRenderContext renders sourcegraph tool messages.
+type SourcegraphToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+ cappedWidth := cappedMessageWidth(width)
+ if !opts.ToolCall.Finished && !opts.Canceled {
+ return pendingTool(sty, "Sourcegraph", opts.Anim)
+ }
+
+ var params tools.SourcegraphParams
+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil {
+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+ }
+
+ toolParams := []string{params.Query}
+ if params.Count != 0 {
+ toolParams = append(toolParams, "count", formatNonZero(params.Count))
+ }
+ if params.ContextWindow != 0 {
+ toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow))
+ }
+
+ header := toolHeader(sty, opts.Status(), "Sourcegraph", cappedWidth, opts.Compact, toolParams...)
+ if opts.Compact {
+ return header
+ }
+
+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+ return joinToolParts(header, earlyState)
+ }
+
+ if opts.Result == nil || opts.Result.Content == "" {
+ return header
+ }
+
+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
return joinToolParts(header, body)
}
@@ -0,0 +1,192 @@
+package chat
+
+import (
+ "encoding/json"
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/charmbracelet/crush/internal/agent/tools"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// -----------------------------------------------------------------------------
+// Todos Tool
+// -----------------------------------------------------------------------------
+
+// TodosToolMessageItem is a message item that represents a todos tool call.
+type TodosToolMessageItem struct {
+ *baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*TodosToolMessageItem)(nil)
+
+// NewTodosToolMessageItem creates a new [TodosToolMessageItem].
+func NewTodosToolMessageItem(
+ sty *styles.Styles,
+ toolCall message.ToolCall,
+ result *message.ToolResult,
+ canceled bool,
+) ToolMessageItem {
+ return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled)
+}
+
+// TodosToolRenderContext renders todos tool messages.
+type TodosToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+ cappedWidth := cappedMessageWidth(width)
+ if !opts.ToolCall.Finished && !opts.Canceled {
+ return pendingTool(sty, "To-Do", opts.Anim)
+ }
+
+ var params tools.TodosParams
+ var meta tools.TodosResponseMetadata
+ var headerText string
+ var body string
+
+ // Parse params for pending state (before result is available).
+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err == nil {
+ completedCount := 0
+ inProgressTask := ""
+ for _, todo := range params.Todos {
+ if todo.Status == "completed" {
+ completedCount++
+ }
+ if todo.Status == "in_progress" {
+ if todo.ActiveForm != "" {
+ inProgressTask = todo.ActiveForm
+ } else {
+ inProgressTask = todo.Content
+ }
+ }
+ }
+
+ // Default display from params (used when pending or no metadata).
+ ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
+ headerText = ratio
+ if inProgressTask != "" {
+ headerText = fmt.Sprintf("%s ยท %s", ratio, inProgressTask)
+ }
+
+ // If we have metadata, use it for richer display.
+ if opts.Result != nil && opts.Result.Metadata != "" {
+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
+ if meta.IsNew {
+ if meta.JustStarted != "" {
+ headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
+ } else {
+ headerText = fmt.Sprintf("created %d todos", meta.Total)
+ }
+ body = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
+ } else {
+ // Build header based on what changed.
+ hasCompleted := len(meta.JustCompleted) > 0
+ hasStarted := meta.JustStarted != ""
+ allCompleted := meta.Completed == meta.Total
+
+ ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
+ if hasCompleted && hasStarted {
+ text := sty.Subtle.Render(fmt.Sprintf(" ยท completed %d, starting next", len(meta.JustCompleted)))
+ headerText = fmt.Sprintf("%s%s", ratio, text)
+ } else if hasCompleted {
+ text := sty.Subtle.Render(fmt.Sprintf(" ยท completed %d", len(meta.JustCompleted)))
+ if allCompleted {
+ text = sty.Subtle.Render(" ยท completed all")
+ }
+ headerText = fmt.Sprintf("%s%s", ratio, text)
+ } else if hasStarted {
+ headerText = fmt.Sprintf("%s%s", ratio, sty.Subtle.Render(" ยท starting task"))
+ } else {
+ headerText = ratio
+ }
+
+ // Build body with details.
+ if allCompleted {
+ // Show all todos when all are completed, like when created.
+ body = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
+ } else if meta.JustStarted != "" {
+ body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") +
+ sty.Base.Render(meta.JustStarted)
+ }
+ }
+ }
+ }
+ }
+
+ toolParams := []string{headerText}
+ header := toolHeader(sty, opts.Status(), "To-Do", cappedWidth, opts.Compact, toolParams...)
+ if opts.Compact {
+ return header
+ }
+
+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+ return joinToolParts(header, earlyState)
+ }
+
+ if body == "" {
+ return header
+ }
+
+ return joinToolParts(header, sty.Tool.Body.Render(body))
+}
+
+// formatTodosList formats a list of todos for display.
+func formatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
+ if len(todos) == 0 {
+ return ""
+ }
+
+ sorted := make([]session.Todo, len(todos))
+ copy(sorted, todos)
+ sortTodos(sorted)
+
+ var lines []string
+ for _, todo := range sorted {
+ var prefix string
+ textStyle := sty.Base
+
+ switch todo.Status {
+ case session.TodoStatusCompleted:
+ prefix = sty.Tool.TodoCompletedIcon.Render(styles.TodoCompletedIcon) + " "
+ case session.TodoStatusInProgress:
+ prefix = sty.Tool.TodoInProgressIcon.Render(inProgressIcon + " ")
+ default:
+ prefix = sty.Tool.TodoPendingIcon.Render(styles.TodoPendingIcon) + " "
+ }
+
+ text := todo.Content
+ if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
+ text = todo.ActiveForm
+ }
+ line := prefix + textStyle.Render(text)
+ line = ansi.Truncate(line, width, "โฆ")
+
+ lines = append(lines, line)
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+// sortTodos sorts todos by status: completed, in_progress, pending.
+func sortTodos(todos []session.Todo) {
+ slices.SortStableFunc(todos, func(a, b session.Todo) int {
+ return statusOrder(a.Status) - statusOrder(b.Status)
+ })
+}
+
+// statusOrder returns the sort order for a todo status.
+func statusOrder(s session.TodoStatus) int {
+ switch s {
+ case session.TodoStatusCompleted:
+ return 0
+ case session.TodoStatusInProgress:
+ return 1
+ default:
+ return 2
+ }
+}
@@ -6,6 +6,8 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
+ "charm.land/lipgloss/v2/tree"
+ "github.com/charmbracelet/crush/internal/agent"
"github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/ui/anim"
@@ -38,8 +40,27 @@ type ToolMessageItem interface {
ToolCall() message.ToolCall
SetToolCall(tc message.ToolCall)
SetResult(res *message.ToolResult)
+ MessageID() string
+ SetMessageID(id string)
}
+// Compactable is an interface for tool items that can render in a compacted mode.
+// When compact mode is enabled, tools render as a compact single-line header.
+type Compactable interface {
+ SetCompact(compact bool)
+}
+
+// SpinningState contains the state passed to SpinningFunc for custom spinning logic.
+type SpinningState struct {
+ ToolCall message.ToolCall
+ Result *message.ToolResult
+ Canceled bool
+}
+
+// SpinningFunc is a function type for custom spinning logic.
+// Returns true if the tool should show the spinning animation.
+type SpinningFunc func(state SpinningState) bool
+
// DefaultToolRenderContext implements the default [ToolRenderer] interface.
type DefaultToolRenderContext struct{}
@@ -54,8 +75,8 @@ type ToolRenderOpts struct {
Result *message.ToolResult
Canceled bool
Anim *anim.Anim
- Expanded bool
- Nested bool
+ ExpandedContent bool
+ Compact bool
IsSpinning bool
PermissionRequested bool
PermissionGranted bool
@@ -100,16 +121,22 @@ type baseToolMessageItem struct {
toolRenderer ToolRenderer
toolCall message.ToolCall
result *message.ToolResult
+ messageID string
canceled bool
permissionRequested bool
permissionGranted bool
// we use this so we can efficiently cache
// tools that have a capped width (e.x bash.. and others)
hasCappedWidth bool
+ // isCompact indicates this tool should render in compact mode.
+ isCompact bool
+ // spinningFunc allows tools to override the default spinning logic.
+ // If nil, uses the default: !toolCall.Finished && !canceled.
+ spinningFunc SpinningFunc
- sty *styles.Styles
- anim *anim.Anim
- expanded bool
+ sty *styles.Styles
+ anim *anim.Anim
+ expandedContent bool
}
// newBaseToolMessageItem is the internal constructor for base tool message items.
@@ -149,37 +176,58 @@ func newBaseToolMessageItem(
// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
//
// It returns a specific tool message item type if implemented, otherwise it
-// returns a generic tool message item.
+// returns a generic tool message item. The messageID is the ID of the assistant
+// message containing this tool call.
func NewToolMessageItem(
sty *styles.Styles,
+ messageID string,
toolCall message.ToolCall,
result *message.ToolResult,
canceled bool,
) ToolMessageItem {
+ var item ToolMessageItem
switch toolCall.Name {
case tools.BashToolName:
- return NewBashToolMessageItem(sty, toolCall, result, canceled)
+ item = NewBashToolMessageItem(sty, toolCall, result, canceled)
case tools.JobOutputToolName:
- return NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
+ item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
case tools.JobKillToolName:
- return NewJobKillToolMessageItem(sty, toolCall, result, canceled)
+ item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
case tools.ViewToolName:
- return NewViewToolMessageItem(sty, toolCall, result, canceled)
+ item = NewViewToolMessageItem(sty, toolCall, result, canceled)
case tools.WriteToolName:
- return NewWriteToolMessageItem(sty, toolCall, result, canceled)
+ item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
case tools.EditToolName:
- return NewEditToolMessageItem(sty, toolCall, result, canceled)
+ item = NewEditToolMessageItem(sty, toolCall, result, canceled)
case tools.MultiEditToolName:
- return NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
+ item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
case tools.GlobToolName:
- return NewGlobToolMessageItem(sty, toolCall, result, canceled)
+ item = NewGlobToolMessageItem(sty, toolCall, result, canceled)
case tools.GrepToolName:
- return NewGrepToolMessageItem(sty, toolCall, result, canceled)
+ item = NewGrepToolMessageItem(sty, toolCall, result, canceled)
case tools.LSToolName:
- return NewLSToolMessageItem(sty, toolCall, result, canceled)
+ item = NewLSToolMessageItem(sty, toolCall, result, canceled)
+ case tools.DownloadToolName:
+ item = NewDownloadToolMessageItem(sty, toolCall, result, canceled)
+ case tools.FetchToolName:
+ item = NewFetchToolMessageItem(sty, toolCall, result, canceled)
+ case tools.SourcegraphToolName:
+ item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
+ case tools.DiagnosticsToolName:
+ item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled)
+ case agent.AgentToolName:
+ item = NewAgentToolMessageItem(sty, toolCall, result, canceled)
+ case tools.AgenticFetchToolName:
+ item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled)
+ case tools.WebFetchToolName:
+ item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled)
+ case tools.WebSearchToolName:
+ item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled)
+ case tools.TodosToolName:
+ item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
default:
// TODO: Implement other tool items
- return newBaseToolMessageItem(
+ item = newBaseToolMessageItem(
sty,
toolCall,
result,
@@ -187,6 +235,14 @@ func NewToolMessageItem(
canceled,
)
}
+ item.SetMessageID(messageID)
+ return item
+}
+
+// SetCompact implements the Compactable interface.
+func (t *baseToolMessageItem) SetCompact(compact bool) {
+ t.isCompact = compact
+ t.clearCache()
}
// ID returns the unique identifier for this tool message item.
@@ -221,6 +277,10 @@ func (t *baseToolMessageItem) Render(width int) string {
style = t.sty.Chat.Message.ToolCallFocused
}
+ if t.isCompact {
+ style = t.sty.Chat.Message.ToolCallCompact
+ }
+
content, height, ok := t.getCachedRender(toolItemWidth)
// if we are spinning or there is no cache rerender
if !ok || t.isSpinning() {
@@ -229,7 +289,8 @@ func (t *baseToolMessageItem) Render(width int) string {
Result: t.result,
Canceled: t.canceled,
Anim: t.anim,
- Expanded: t.expanded,
+ ExpandedContent: t.expandedContent,
+ Compact: t.isCompact,
PermissionRequested: t.permissionRequested,
PermissionGranted: t.permissionGranted,
IsSpinning: t.isSpinning(),
@@ -260,6 +321,16 @@ func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
t.clearCache()
}
+// MessageID returns the ID of the message containing this tool call.
+func (t *baseToolMessageItem) MessageID() string {
+ return t.messageID
+}
+
+// SetMessageID sets the ID of the message containing this tool call.
+func (t *baseToolMessageItem) SetMessageID(id string) {
+ t.messageID = id
+}
+
// SetPermissionRequested sets whether permission has been requested for this tool call.
// TODO: Consider merging with SetPermissionGranted and add an interface for
// permission management.
@@ -278,12 +349,24 @@ func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
// isSpinning returns true if the tool should show animation.
func (t *baseToolMessageItem) isSpinning() bool {
+ if t.spinningFunc != nil {
+ return t.spinningFunc(SpinningState{
+ ToolCall: t.toolCall,
+ Result: t.result,
+ Canceled: t.canceled,
+ })
+ }
return !t.toolCall.Finished && !t.canceled
}
+// SetSpinningFunc sets a custom function to determine if the tool should spin.
+func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
+ t.spinningFunc = fn
+}
+
// ToggleExpanded toggles the expanded state of the thinking box.
func (t *baseToolMessageItem) ToggleExpanded() {
- t.expanded = !t.expanded
+ t.expandedContent = !t.expandedContent
t.clearCache()
}
@@ -487,10 +570,10 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
// Add truncation message if needed.
if len(lines) > maxLines && !expanded {
- truncMsg := sty.Tool.ContentCodeTruncation.
+ out = append(out, sty.Tool.ContentCodeTruncation.
Width(bodyWidth).
- Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
- out = append([]string{truncMsg}, out...)
+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
+ )
}
return sty.Tool.Body.Render(strings.Join(out, "\n"))
@@ -551,7 +634,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
Width(bodyWidth)
// Use split view for wide terminals.
- if width > 120 {
+ if width > maxTextWidth {
formatter = formatter.Split()
}
@@ -574,6 +657,23 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
return sty.Tool.Body.Render(formatted)
}
+// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
+// Returns empty string if timeout is 0.
+func formatTimeout(timeout int) string {
+ if timeout == 0 {
+ return ""
+ }
+ return fmt.Sprintf("%ds", timeout)
+}
+
+// formatNonZero returns string representation of non-zero integers, empty string for zero.
+func formatNonZero(value int) string {
+ if value == 0 {
+ return ""
+ }
+ return fmt.Sprintf("%d", value)
+}
+
// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
bodyWidth := width - toolBodyLeftPaddingTotal
@@ -584,7 +684,7 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
Width(bodyWidth)
// Use split view for wide terminals.
- if width > 120 {
+ if width > maxTextWidth {
formatter = formatter.Split()
}
@@ -614,3 +714,62 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
return sty.Tool.Body.Render(formatted)
}
+
+// roundedEnumerator creates a tree enumerator with rounded corners.
+func roundedEnumerator(lPadding, width int) tree.Enumerator {
+ if width == 0 {
+ width = 2
+ }
+ if lPadding == 0 {
+ lPadding = 1
+ }
+ return func(children tree.Children, index int) string {
+ line := strings.Repeat("โ", width)
+ padding := strings.Repeat(" ", lPadding)
+ if children.Length()-1 == index {
+ return padding + "โฐ" + line
+ }
+ return padding + "โ" + line
+ }
+}
+
+// toolOutputMarkdownContent renders markdown content with optional truncation.
+func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
+ content = strings.ReplaceAll(content, "\r\n", "\n")
+ content = strings.ReplaceAll(content, "\t", " ")
+ content = strings.TrimSpace(content)
+
+ // Cap width for readability.
+ if width > maxTextWidth {
+ width = maxTextWidth
+ }
+
+ renderer := common.PlainMarkdownRenderer(sty, width)
+ rendered, err := renderer.Render(content)
+ if err != nil {
+ return toolOutputPlainContent(sty, content, width, expanded)
+ }
+
+ lines := strings.Split(rendered, "\n")
+ maxLines := responseContextHeight
+ if expanded {
+ maxLines = len(lines)
+ }
+
+ var out []string
+ for i, ln := range lines {
+ if i >= maxLines {
+ break
+ }
+ out = append(out, ln)
+ }
+
+ if len(lines) > maxLines && !expanded {
+ out = append(out, sty.Tool.ContentTruncation.
+ Width(width).
+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
+ )
+ }
+
+ return sty.Tool.Body.Render(strings.Join(out, "\n"))
+}
@@ -68,7 +68,7 @@ func (f *ModelsList) SetSelected(index int) {
f.List.SetSelected(index)
for {
- selectedItem := f.List.SelectedItem()
+ selectedItem := f.SelectedItem()
if _, ok := selectedItem.(*ModelItem); ok {
return
}
@@ -104,7 +104,7 @@ func (f *ModelsList) SetSelectedItem(itemID string) {
func (f *ModelsList) SelectNext() (v bool) {
for {
v = f.List.SelectNext()
- selectedItem := f.List.SelectedItem()
+ selectedItem := f.SelectedItem()
if _, ok := selectedItem.(*ModelItem); ok {
return v
}
@@ -116,7 +116,7 @@ func (f *ModelsList) SelectNext() (v bool) {
func (f *ModelsList) SelectPrev() (v bool) {
for {
v = f.List.SelectPrev()
- selectedItem := f.List.SelectedItem()
+ selectedItem := f.SelectedItem()
if _, ok := selectedItem.(*ModelItem); ok {
return v
}
@@ -127,7 +127,7 @@ func (f *ModelsList) SelectPrev() (v bool) {
func (f *ModelsList) SelectFirst() (v bool) {
v = f.List.SelectFirst()
for {
- selectedItem := f.List.SelectedItem()
+ selectedItem := f.SelectedItem()
if _, ok := selectedItem.(*ModelItem); ok {
return v
}
@@ -139,7 +139,7 @@ func (f *ModelsList) SelectFirst() (v bool) {
func (f *ModelsList) SelectLast() (v bool) {
v = f.List.SelectLast()
for {
- selectedItem := f.List.SelectedItem()
+ selectedItem := f.SelectedItem()
if _, ok := selectedItem.(*ModelItem); ok {
return v
}
@@ -149,18 +149,18 @@ func (f *ModelsList) SelectLast() (v bool) {
// IsSelectedFirst checks if the selected item is the first model item.
func (f *ModelsList) IsSelectedFirst() bool {
- originalIndex := f.List.Selected()
+ originalIndex := f.Selected()
f.SelectFirst()
- isFirst := f.List.Selected() == originalIndex
+ isFirst := f.Selected() == originalIndex
f.List.SetSelected(originalIndex)
return isFirst
}
// IsSelectedLast checks if the selected item is the last model item.
func (f *ModelsList) IsSelectedLast() bool {
- originalIndex := f.List.Selected()
+ originalIndex := f.Selected()
f.SelectLast()
- isLast := f.List.Selected() == originalIndex
+ isLast := f.Selected() == originalIndex
f.List.SetSelected(originalIndex)
return isLast
}
@@ -77,6 +77,12 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
items := make([]list.Item, len(msgs))
for i, msg := range msgs {
m.idInxMap[msg.ID()] = i
+ // Register nested tool IDs for tools that contain nested tools.
+ if container, ok := msg.(chat.NestedToolContainer); ok {
+ for _, nested := range container.NestedTools() {
+ m.idInxMap[nested.ID()] = i
+ }
+ }
items[i] = msg
}
m.list.SetItems(items...)
@@ -89,11 +95,41 @@ func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
indexOffset := m.list.Len()
for i, msg := range msgs {
m.idInxMap[msg.ID()] = indexOffset + i
+ // Register nested tool IDs for tools that contain nested tools.
+ if container, ok := msg.(chat.NestedToolContainer); ok {
+ for _, nested := range container.NestedTools() {
+ m.idInxMap[nested.ID()] = indexOffset + i
+ }
+ }
items[i] = msg
}
m.list.AppendItems(items...)
}
+// UpdateNestedToolIDs updates the ID map for nested tools within a container.
+// Call this after modifying nested tools to ensure animations work correctly.
+func (m *Chat) UpdateNestedToolIDs(containerID string) {
+ idx, ok := m.idInxMap[containerID]
+ if !ok {
+ return
+ }
+
+ item, ok := m.list.ItemAt(idx).(chat.MessageItem)
+ if !ok {
+ return
+ }
+
+ container, ok := item.(chat.NestedToolContainer)
+ if !ok {
+ return
+ }
+
+ // Register all nested tool IDs to point to the container's index.
+ for _, nested := range container.NestedTools() {
+ m.idInxMap[nested.ID()] = idx
+ }
+}
+
// Animate animates items in the chat list. Only propagates animation messages
// to visible items to save CPU. When items are not visible, their animation ID
// is tracked so it can be restarted when they become visible again.
@@ -200,8 +200,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case pubsub.Event[message.Message]:
- // TODO: handle nested messages for agentic tools
- if m.session == nil || msg.Payload.SessionID != m.session.ID {
+ // Check if this is a child session message for an agent tool.
+ if m.session == nil {
+ break
+ }
+ if msg.Payload.SessionID != m.session.ID {
+ // This might be a child session message from an agent tool.
+ if cmd := m.handleChildSessionMessage(msg); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
break
}
switch msg.Type {
@@ -384,6 +391,9 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
}
+ // Load nested tool calls for agent/agentic_fetch tools.
+ m.loadNestedToolCalls(items)
+
// If the user switches between sessions while the agent is working we want
// to make sure the animations are shown.
for _, item := range items {
@@ -402,6 +412,64 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
return tea.Batch(cmds...)
}
+// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
+func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
+ for _, item := range items {
+ nestedContainer, ok := item.(chat.NestedToolContainer)
+ if !ok {
+ continue
+ }
+ toolItem, ok := item.(chat.ToolMessageItem)
+ if !ok {
+ continue
+ }
+
+ tc := toolItem.ToolCall()
+ messageID := toolItem.MessageID()
+
+ // Get the agent tool session ID.
+ agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
+
+ // Fetch nested messages.
+ nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
+ if err != nil || len(nestedMsgs) == 0 {
+ continue
+ }
+
+ // Build tool result map for nested messages.
+ nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
+ for i := range nestedMsgs {
+ nestedMsgPtrs[i] = &nestedMsgs[i]
+ }
+ nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
+
+ // Extract nested tool items.
+ var nestedTools []chat.ToolMessageItem
+ for _, nestedMsg := range nestedMsgPtrs {
+ nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
+ for _, nestedItem := range nestedItems {
+ if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
+ // Mark nested tools as simple (compact) rendering.
+ if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
+ simplifiable.SetCompact(true)
+ }
+ nestedTools = append(nestedTools, nestedToolItem)
+ }
+ }
+ }
+
+ // Recursively load nested tool calls for any agent tools within.
+ nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
+ for i, nt := range nestedTools {
+ nestedMessageItems[i] = nt
+ }
+ m.loadNestedToolCalls(nestedMessageItems)
+
+ // Set nested tools on the parent.
+ nestedContainer.SetNestedTools(nestedTools)
+ }
+}
+
// appendSessionMessage appends a new message to the current session in the chat
// if the message is a tool result it will update the corresponding tool call message
func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
@@ -465,7 +533,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
}
}
if existingToolItem == nil {
- items = append(items, chat.NewToolMessageItem(m.com.Styles, tc, nil, false))
+ items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
}
}
@@ -484,6 +552,92 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
return tea.Batch(cmds...)
}
+// handleChildSessionMessage handles messages from child sessions (agent tools).
+func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
+ var cmds []tea.Cmd
+
+ // Only process messages with tool calls or results.
+ if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
+ return nil
+ }
+
+ // Check if this is an agent tool session and parse it.
+ childSessionID := event.Payload.SessionID
+ _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
+ if !ok {
+ return nil
+ }
+
+ // Find the parent agent tool item.
+ var agentItem chat.NestedToolContainer
+ for i := 0; i < m.chat.Len(); i++ {
+ item := m.chat.MessageItem(toolCallID)
+ if item == nil {
+ continue
+ }
+ if agent, ok := item.(chat.NestedToolContainer); ok {
+ if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
+ if toolMessageItem.ToolCall().ID == toolCallID {
+ // Verify this agent belongs to the correct parent message.
+ // We can't directly check parentMessageID on the item, so we trust the session parsing.
+ agentItem = agent
+ break
+ }
+ }
+ }
+ }
+
+ if agentItem == nil {
+ return nil
+ }
+
+ // Get existing nested tools.
+ nestedTools := agentItem.NestedTools()
+
+ // Update or create nested tool calls.
+ for _, tc := range event.Payload.ToolCalls() {
+ found := false
+ for _, existingTool := range nestedTools {
+ if existingTool.ToolCall().ID == tc.ID {
+ existingTool.SetToolCall(tc)
+ found = true
+ break
+ }
+ }
+ if !found {
+ // Create a new nested tool item.
+ nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
+ if simplifiable, ok := nestedItem.(chat.Compactable); ok {
+ simplifiable.SetCompact(true)
+ }
+ if animatable, ok := nestedItem.(chat.Animatable); ok {
+ if cmd := animatable.StartAnimation(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+ nestedTools = append(nestedTools, nestedItem)
+ }
+ }
+
+ // Update nested tool results.
+ for _, tr := range event.Payload.ToolResults() {
+ for _, nestedTool := range nestedTools {
+ if nestedTool.ToolCall().ID == tr.ToolCallID {
+ nestedTool.SetResult(&tr)
+ break
+ }
+ }
+ }
+
+ // Update the agent item with the new nested tools.
+ agentItem.SetNestedTools(nestedTools)
+
+ // Update the chat so it updates the index map for animations to work as expected
+ m.chat.UpdateNestedToolIDs(toolCallID)
+
+ return tea.Batch(cmds...)
+}
+
func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
var cmds []tea.Cmd
@@ -210,6 +210,7 @@ type Styles struct {
ErrorDetails lipgloss.Style
Attachment lipgloss.Style
ToolCallFocused lipgloss.Style
+ ToolCallCompact lipgloss.Style
ToolCallBlurred lipgloss.Style
SectionHeader lipgloss.Style
@@ -277,6 +278,15 @@ type Styles struct {
// Agent task styles
AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold)
AgentPrompt lipgloss.Style // Agent prompt text
+
+ // Agentic fetch styles
+ AgenticFetchPromptTag lipgloss.Style // Agentic fetch prompt tag (green background, bold)
+
+ // Todo styles
+ TodoRatio lipgloss.Style // Todo ratio (e.g., "2/5")
+ TodoCompletedIcon lipgloss.Style // Completed todo icon
+ TodoInProgressIcon lipgloss.Style // In-progress todo icon
+ TodoPendingIcon lipgloss.Style // Pending todo icon
}
// Dialog styles
@@ -1022,6 +1032,15 @@ func DefaultStyles() Styles {
s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white)
s.Tool.AgentPrompt = s.Muted
+ // Agentic fetch styles
+ s.Tool.AgenticFetchPromptTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(green).Foreground(border)
+
+ // Todo styles
+ s.Tool.TodoRatio = base.Foreground(blueDark)
+ s.Tool.TodoCompletedIcon = base.Foreground(green)
+ s.Tool.TodoInProgressIcon = base.Foreground(greenDark)
+ s.Tool.TodoPendingIcon = base.Foreground(fgMuted)
+
// Buttons
s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
s.ButtonBlur = s.Base.Background(bgSubtle)
@@ -1099,6 +1118,8 @@ func DefaultStyles() Styles {
BorderLeft(true).
BorderForeground(greenDark)
s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
+ // No padding or border for compact tool calls within messages
+ s.Chat.Message.ToolCallCompact = s.Muted
s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
// Thinking section styles