Merge pull request #1778 from charmbracelet/add-more-tools

Ayman Bagabas created

Add more tools

Change summary

internal/ui/chat/agent.go         | 302 +++++++++++++++++++++++++++++++++
internal/ui/chat/bash.go          |  10 
internal/ui/chat/diagnostics.go   |  68 +++++++
internal/ui/chat/fetch.go         | 192 ++++++++++++++++++++
internal/ui/chat/file.go          |  90 ++++++++-
internal/ui/chat/messages.go      |   1 
internal/ui/chat/search.go        |  80 +++++++
internal/ui/chat/todos.go         | 192 ++++++++++++++++++++
internal/ui/chat/tools.go         | 207 +++++++++++++++++++--
internal/ui/dialog/models_list.go |  18 
internal/ui/model/chat.go         |  36 +++
internal/ui/model/ui.go           | 160 +++++++++++++++++
internal/ui/styles/styles.go      |  21 ++
13 files changed, 1,313 insertions(+), 64 deletions(-)

Detailed changes

internal/ui/chat/agent.go ๐Ÿ”—

@@ -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), &params)
+
+	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), &params)
+
+	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
+}

internal/ui/chat/bash.go ๐Ÿ”—

@@ -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)
 }
 

internal/ui/chat/diagnostics.go ๐Ÿ”—

@@ -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), &params)
+
+	// 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)
+}

internal/ui/chat/fetch.go ๐Ÿ”—

@@ -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), &params); 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), &params); 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), &params); 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)
+}

internal/ui/chat/file.go ๐Ÿ”—

@@ -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), &params); 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)
 }

internal/ui/chat/messages.go ๐Ÿ”—

@@ -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,

internal/ui/chat/search.go ๐Ÿ”—

@@ -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), &params); 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)
 }

internal/ui/chat/todos.go ๐Ÿ”—

@@ -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), &params); 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
+	}
+}

internal/ui/chat/tools.go ๐Ÿ”—

@@ -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"))
+}

internal/ui/dialog/models_list.go ๐Ÿ”—

@@ -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
 }

internal/ui/model/chat.go ๐Ÿ”—

@@ -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.

internal/ui/model/ui.go ๐Ÿ”—

@@ -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
 

internal/ui/styles/styles.go ๐Ÿ”—

@@ -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