chore: add back old fetch

Kujtim Hoxha created

Change summary

go.mod                                                     |   2 
internal/agent/agent_tool.go                               |   4 
internal/agent/agentic_fetch_tool.go                       |  27 
internal/agent/coordinator.go                              |   8 
internal/agent/templates/agentic_fetch.md                  |  19 
internal/agent/templates/agentic_fetch_prompt.md.tpl       |   0 
internal/agent/tools/fetch.go                              | 193 ++++++++
internal/agent/tools/fetch.md                              |  45 +
internal/agent/tools/fetch_helpers.go                      |  26 +
internal/agent/tools/fetch_types.go                        |  26 
internal/agent/tools/view.go                               |   7 
internal/agent/tools/web_fetch.go                          |   5 
internal/config/config.go                                  |   1 
internal/tui/components/chat/chat.go                       |   4 
internal/tui/components/chat/messages/renderer.go          |  56 ++
internal/tui/components/chat/messages/tool.go              |  38 +
internal/tui/components/dialogs/permissions/permissions.go |  21 
17 files changed, 435 insertions(+), 47 deletions(-)

Detailed changes

go.mod 🔗

@@ -6,6 +6,7 @@ require (
 	charm.land/fantasy v0.1.4
 	github.com/JohannesKaufmann/html-to-markdown v1.6.0
 	github.com/MakeNowJust/heredoc v1.0.0
+	github.com/PuerkitoBio/goquery v1.10.3
 	github.com/alecthomas/chroma/v2 v2.20.0
 	github.com/atotto/clipboard v0.1.4
 	github.com/aymanbagabas/go-udiff v0.3.1
@@ -56,7 +57,6 @@ require (
 	cloud.google.com/go/compute/metadata v0.8.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
-	github.com/PuerkitoBio/goquery v1.10.3 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
 	github.com/aws/aws-sdk-go-v2 v1.39.4 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect

internal/agent/agent_tool.go 🔗

@@ -3,7 +3,6 @@ package agent
 import (
 	"context"
 	_ "embed"
-	"encoding/json"
 	"errors"
 	"fmt"
 
@@ -43,9 +42,6 @@ func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error)
 		AgentToolName,
 		string(agentToolDescription),
 		func(ctx context.Context, params AgentParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
-			if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-				return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-			}
 			if params.Prompt == "" {
 				return fantasy.NewTextErrorResponse("prompt is required"), nil
 			}

internal/agent/fetch_tool.go → internal/agent/agentic_fetch_tool.go 🔗

@@ -3,7 +3,6 @@ package agent
 import (
 	"context"
 	_ "embed"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"net/http"
@@ -17,13 +16,13 @@ import (
 	"github.com/charmbracelet/crush/internal/permission"
 )
 
-//go:embed templates/fetch.md
-var fetchToolDescription []byte
+//go:embed templates/agentic_fetch.md
+var agenticFetchToolDescription []byte
 
-//go:embed templates/fetch_prompt.md.tpl
-var fetchPromptTmpl []byte
+//go:embed templates/agentic_fetch_prompt.md.tpl
+var agenticFetchPromptTmpl []byte
 
-func (c *coordinator) fetchTool(_ context.Context, client *http.Client) (fantasy.AgentTool, error) {
+func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (fantasy.AgentTool, error) {
 	if client == nil {
 		client = &http.Client{
 			Timeout: 30 * time.Second,
@@ -36,13 +35,9 @@ func (c *coordinator) fetchTool(_ context.Context, client *http.Client) (fantasy
 	}
 
 	return fantasy.NewAgentTool(
-		tools.FetchToolName,
-		string(fetchToolDescription),
-		func(ctx context.Context, params tools.FetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
-			if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-				return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-			}
-
+		tools.AgenticFetchToolName,
+		string(agenticFetchToolDescription),
+		func(ctx context.Context, params tools.AgenticFetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 			if params.URL == "" {
 				return fantasy.NewTextErrorResponse("url is required"), nil
 			}
@@ -66,10 +61,10 @@ func (c *coordinator) fetchTool(_ context.Context, client *http.Client) (fantasy
 					SessionID:   sessionID,
 					Path:        c.cfg.WorkingDir(),
 					ToolCallID:  call.ID,
-					ToolName:    tools.FetchToolName,
+					ToolName:    tools.AgenticFetchToolName,
 					Action:      "fetch",
 					Description: fmt.Sprintf("Fetch and analyze content from URL: %s", params.URL),
-					Params:      tools.FetchPermissionsParams(params),
+					Params:      tools.AgenticFetchPermissionsParams(params),
 				},
 			)
 
@@ -113,7 +108,7 @@ func (c *coordinator) fetchTool(_ context.Context, client *http.Client) (fantasy
 				prompt.WithWorkingDir(tmpDir),
 			}
 
-			promptTemplate, err := prompt.NewPrompt("fetch", string(fetchPromptTmpl), promptOpts...)
+			promptTemplate, err := prompt.NewPrompt("agentic_fetch", string(agenticFetchPromptTmpl), promptOpts...)
 			if err != nil {
 				return fantasy.ToolResponse{}, fmt.Errorf("error creating prompt: %s", err)
 			}

internal/agent/coordinator.go 🔗

@@ -321,12 +321,12 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		allTools = append(allTools, agentTool)
 	}
 
-	if slices.Contains(agent.AllowedTools, tools.FetchToolName) {
-		fetchTool, err := c.fetchTool(ctx, nil)
+	if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
+		agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
 		if err != nil {
 			return nil, err
 		}
-		allTools = append(allTools, fetchTool)
+		allTools = append(allTools, agenticFetchTool)
 	}
 
 	allTools = append(allTools,
@@ -334,6 +334,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
 		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
 		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
 		tools.NewGlobTool(c.cfg.WorkingDir()),
 		tools.NewGrepTool(c.cfg.WorkingDir()),
 		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
@@ -663,7 +664,6 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
 		}
 	}
 
-	// TODO: make sure we have
 	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
 	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
 

internal/agent/templates/fetch.md → internal/agent/templates/agentic_fetch.md 🔗

@@ -1,4 +1,18 @@
-Fetches content from a specified URL and processes it using an AI model.
+Fetches content from a specified URL and processes it using an AI model to extract information or answer questions.
+
+<when_to_use>
+Use this tool when you need to:
+- Extract specific information from a webpage (e.g., "get pricing info")
+- Answer questions about web content (e.g., "what does this article say about X?")
+- Summarize or analyze web pages
+- Find specific data within large pages
+- Interpret or process web content with AI
+
+DO NOT use this tool when:
+- You just need raw content without analysis (use fetch instead - faster and cheaper)
+- You want direct access to API responses or JSON (use fetch instead)
+- You don't need the content processed or interpreted (use fetch instead)
+</when_to_use>
 
 <usage>
 - Takes a URL and a prompt as input
@@ -18,6 +32,7 @@ Fetches content from a specified URL and processes it using an AI model.
 - Results may be summarized if the content is very large
 - For very large pages, the content will be saved to a temporary file and the agent will have access to grep/view tools to analyze it
 - When a URL redirects to a different host, the tool will inform you and provide the redirect URL. You should then make a new fetch request with the redirect URL to fetch the content.
+- This tool uses AI processing and costs more tokens than the simple fetch tool
   </usage_notes>
 
 <limitations>
@@ -25,10 +40,12 @@ Fetches content from a specified URL and processes it using an AI model.
 - Only supports HTTP and HTTPS protocols
 - Cannot handle authentication or cookies
 - Some websites may block automated requests
+- Uses additional tokens for AI processing
 </limitations>
 
 <tips>
 - Be specific in your prompt about what information you want to extract
 - For complex pages, ask the agent to focus on specific sections
 - The agent has access to grep and view tools when analyzing large pages
+- If you just need raw content, use the fetch tool instead to save tokens
 </tips>

internal/agent/tools/fetch.go 🔗

@@ -0,0 +1,193 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+	"unicode/utf8"
+
+	"charm.land/fantasy"
+	md "github.com/JohannesKaufmann/html-to-markdown"
+	"github.com/PuerkitoBio/goquery"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+type fetchTool struct {
+	client      *http.Client
+	permissions permission.Service
+	workingDir  string
+}
+
+const FetchToolName = "fetch"
+
+//go:embed fetch.md
+var fetchDescription []byte
+
+func NewFetchTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
+	if client == nil {
+		client = &http.Client{
+			Timeout: 30 * time.Second,
+			Transport: &http.Transport{
+				MaxIdleConns:        100,
+				MaxIdleConnsPerHost: 10,
+				IdleConnTimeout:     90 * time.Second,
+			},
+		}
+	}
+
+	return fantasy.NewAgentTool(
+		FetchToolName,
+		string(fetchDescription),
+		func(ctx context.Context, params FetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.URL == "" {
+				return fantasy.NewTextErrorResponse("URL parameter is required"), nil
+			}
+
+			format := strings.ToLower(params.Format)
+			if format != "text" && format != "markdown" && format != "html" {
+				return fantasy.NewTextErrorResponse("Format must be one of: text, markdown, html"), nil
+			}
+
+			if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
+				return fantasy.NewTextErrorResponse("URL must start with http:// or https://"), nil
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
+			}
+
+			p := permissions.Request(
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        workingDir,
+					ToolCallID:  call.ID,
+					ToolName:    FetchToolName,
+					Action:      "fetch",
+					Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
+					Params:      FetchPermissionsParams(params),
+				},
+			)
+
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			// Handle timeout with context
+			requestCtx := ctx
+			if params.Timeout > 0 {
+				maxTimeout := 120 // 2 minutes
+				if params.Timeout > maxTimeout {
+					params.Timeout = maxTimeout
+				}
+				var cancel context.CancelFunc
+				requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
+				defer cancel()
+			}
+
+			req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
+			}
+
+			req.Header.Set("User-Agent", "crush/1.0")
+
+			resp, err := client.Do(req)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
+			}
+			defer resp.Body.Close()
+
+			if resp.StatusCode != http.StatusOK {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
+			}
+
+			maxSize := int64(5 * 1024 * 1024) // 5MB
+			body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
+			if err != nil {
+				return fantasy.NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
+			}
+
+			content := string(body)
+
+			isValidUt8 := utf8.ValidString(content)
+			if !isValidUt8 {
+				return fantasy.NewTextErrorResponse("Response content is not valid UTF-8"), nil
+			}
+			contentType := resp.Header.Get("Content-Type")
+
+			switch format {
+			case "text":
+				if strings.Contains(contentType, "text/html") {
+					text, err := extractTextFromHTML(content)
+					if err != nil {
+						return fantasy.NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil
+					}
+					content = text
+				}
+
+			case "markdown":
+				if strings.Contains(contentType, "text/html") {
+					markdown, err := convertHTMLToMarkdown(content)
+					if err != nil {
+						return fantasy.NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil
+					}
+					content = markdown
+				}
+
+				content = "```\n" + content + "\n```"
+
+			case "html":
+				// return only the body of the HTML document
+				if strings.Contains(contentType, "text/html") {
+					doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
+					if err != nil {
+						return fantasy.NewTextErrorResponse("Failed to parse HTML: " + err.Error()), nil
+					}
+					body, err := doc.Find("body").Html()
+					if err != nil {
+						return fantasy.NewTextErrorResponse("Failed to extract body from HTML: " + err.Error()), nil
+					}
+					if body == "" {
+						return fantasy.NewTextErrorResponse("No body content found in HTML"), nil
+					}
+					content = "<html>\n<body>\n" + body + "\n</body>\n</html>"
+				}
+			}
+			// calculate byte size of content
+			contentSize := int64(len(content))
+			if contentSize > MaxReadSize {
+				content = content[:MaxReadSize]
+				content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxReadSize)
+			}
+
+			return fantasy.NewTextResponse(content), nil
+		})
+}
+
+func extractTextFromHTML(html string) (string, error) {
+	doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
+	if err != nil {
+		return "", err
+	}
+
+	text := doc.Find("body").Text()
+	text = strings.Join(strings.Fields(text), " ")
+
+	return text, nil
+}
+
+func convertHTMLToMarkdown(html string) (string, error) {
+	converter := md.NewConverter("", true, nil)
+
+	markdown, err := converter.ConvertString(html)
+	if err != nil {
+		return "", err
+	}
+
+	return markdown, nil
+}

internal/agent/tools/fetch.md 🔗

@@ -0,0 +1,45 @@
+Fetches raw content from URL and returns it in specified format without any AI processing.
+
+<when_to_use>
+Use this tool when you need:
+- Raw, unprocessed content from a URL
+- Direct access to API responses or JSON data
+- HTML/text/markdown content without interpretation
+- Simple, fast content retrieval without analysis
+- To save tokens by avoiding AI processing
+
+DO NOT use this tool when you need to:
+- Extract specific information from a webpage (use agentic_fetch instead)
+- Answer questions about web content (use agentic_fetch instead)
+- Analyze or summarize web pages (use agentic_fetch instead)
+</when_to_use>
+
+<usage>
+- Provide URL to fetch content from
+- Specify desired output format (text, markdown, or html)
+- Optional timeout for request
+</usage>
+
+<features>
+- Supports three output formats: text, markdown, html
+- Auto-handles HTTP redirects
+- Fast and lightweight - no AI processing
+- Sets reasonable timeouts to prevent hanging
+- Validates input parameters before requests
+</features>
+
+<limitations>
+- Max response size: 5MB
+- Only supports HTTP and HTTPS protocols
+- Cannot handle authentication or cookies
+- Some websites may block automated requests
+- Returns raw content only - no analysis or extraction
+</limitations>
+
+<tips>
+- Use text format for plain text content or simple API responses
+- Use markdown format for content that should be rendered with formatting
+- Use html format when you need raw HTML structure
+- Set appropriate timeouts for potentially slow websites
+- If the user asks to analyze or extract from a page, use agentic_fetch instead
+</tips>

internal/agent/tools/fetch_helpers.go 🔗

@@ -1,7 +1,9 @@
 package tools
 
 import (
+	"bytes"
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -52,6 +54,13 @@ func FetchURLAndConvert(ctx context.Context, client *http.Client, url string) (s
 			return "", fmt.Errorf("failed to convert HTML to markdown: %w", err)
 		}
 		content = markdown
+	} else if strings.Contains(contentType, "application/json") || strings.Contains(contentType, "text/json") {
+		// Format JSON for better readability.
+		formatted, err := FormatJSON(content)
+		if err == nil {
+			content = formatted
+		}
+		// If formatting fails, keep original content.
 	}
 
 	return content, nil
@@ -68,3 +77,20 @@ func ConvertHTMLToMarkdown(html string) (string, error) {
 
 	return markdown, nil
 }
+
+// FormatJSON formats JSON content with proper indentation.
+func FormatJSON(content string) (string, error) {
+	var data interface{}
+	if err := json.Unmarshal([]byte(content), &data); err != nil {
+		return "", err
+	}
+
+	var buf bytes.Buffer
+	encoder := json.NewEncoder(&buf)
+	encoder.SetIndent("", "  ")
+	if err := encoder.Encode(data); err != nil {
+		return "", err
+	}
+
+	return buf.String(), nil
+}

internal/agent/tools/fetch_types.go 🔗

@@ -1,7 +1,7 @@
 package tools
 
-// FetchToolName is the name of the fetch tool.
-const FetchToolName = "fetch"
+// AgenticFetchToolName is the name of the agentic fetch tool.
+const AgenticFetchToolName = "agentic_fetch"
 
 // WebFetchToolName is the name of the web_fetch tool.
 const WebFetchToolName = "web_fetch"
@@ -9,14 +9,14 @@ const WebFetchToolName = "web_fetch"
 // LargeContentThreshold is the size threshold for saving content to a file.
 const LargeContentThreshold = 50000 // 50KB
 
-// FetchParams defines the parameters for the fetch tool.
-type FetchParams struct {
+// AgenticFetchParams defines the parameters for the agentic fetch tool.
+type AgenticFetchParams struct {
 	URL    string `json:"url" description:"The URL to fetch content from"`
 	Prompt string `json:"prompt" description:"The prompt to run on the fetched content"`
 }
 
-// FetchPermissionsParams defines the permission parameters for the fetch tool.
-type FetchPermissionsParams struct {
+// AgenticFetchPermissionsParams defines the permission parameters for the agentic fetch tool.
+type AgenticFetchPermissionsParams struct {
 	URL    string `json:"url"`
 	Prompt string `json:"prompt"`
 }
@@ -25,3 +25,17 @@ type FetchPermissionsParams struct {
 type WebFetchParams struct {
 	URL string `json:"url" description:"The URL to fetch content from"`
 }
+
+// FetchParams defines the parameters for the simple fetch tool.
+type FetchParams struct {
+	URL     string `json:"url" description:"The URL to fetch content from"`
+	Format  string `json:"format" description:"The format to return the content in (text, markdown, or html)"`
+	Timeout int    `json:"timeout,omitempty" description:"Optional timeout in seconds (max 120)"`
+}
+
+// FetchPermissionsParams defines the permission parameters for the simple fetch tool.
+type FetchPermissionsParams struct {
+	URL     string `json:"url"`
+	Format  string `json:"format"`
+	Timeout int    `json:"timeout,omitempty"`
+}

internal/agent/tools/view.go 🔗

@@ -288,8 +288,13 @@ type LineScanner struct {
 }
 
 func NewLineScanner(r io.Reader) *LineScanner {
+	scanner := bufio.NewScanner(r)
+	// Increase buffer size to handle large lines (e.g., minified JSON, HTML)
+	// Default is 64KB, set to 1MB
+	buf := make([]byte, 0, 64*1024)
+	scanner.Buffer(buf, 1024*1024)
 	return &LineScanner{
-		scanner: bufio.NewScanner(r),
+		scanner: scanner,
 	}
 }
 

internal/agent/tools/web_fetch.go 🔗

@@ -3,7 +3,6 @@ package tools
 import (
 	"context"
 	_ "embed"
-	"encoding/json"
 	"fmt"
 	"net/http"
 	"os"
@@ -33,10 +32,6 @@ func NewWebFetchTool(workingDir string, client *http.Client) fantasy.AgentTool {
 		WebFetchToolName,
 		string(webFetchToolDescription),
 		func(ctx context.Context, params WebFetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
-			if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-				return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-			}
-
 			if params.URL == "" {
 				return fantasy.NewTextErrorResponse("url is required"), nil
 			}

internal/config/config.go 🔗

@@ -475,6 +475,7 @@ func allToolNames() []string {
 		"lsp_diagnostics",
 		"lsp_references",
 		"fetch",
+		"agentic_fetch",
 		"glob",
 		"grep",
 		"ls",

internal/tui/components/chat/chat.go 🔗

@@ -636,8 +636,8 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
 	for _, tc := range msg.ToolCalls() {
 		options := m.buildToolCallOptions(tc, msg, toolResultMap)
 		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
-		// If this tool call is the agent tool, fetch nested tool calls
-		if tc.Name == agent.AgentToolName || tc.Name == tools.FetchToolName {
+		// If this tool call is the agent tool or agentic fetch, fetch nested tool calls
+		if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
 			agentToolSessionID := m.app.Sessions.CreateAgentToolSessionID(msg.ID, tc.ID)
 			nestedMessages, _ := m.app.Messages.List(context.Background(), agentToolSessionID)
 			nestedToolResultMap := m.buildToolResultMap(nestedMessages)

internal/tui/components/chat/messages/renderer.go 🔗

@@ -168,7 +168,8 @@ func init() {
 	registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
 	registry.register(tools.MultiEditToolName, func() renderer { return multiEditRenderer{} })
 	registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
-	registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} })
+	registry.register(tools.FetchToolName, func() renderer { return simpleFetchRenderer{} })
+	registry.register(tools.AgenticFetchToolName, func() renderer { return agenticFetchRenderer{} })
 	registry.register(tools.WebFetchToolName, func() renderer { return webFetchRenderer{} })
 	registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
 	registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
@@ -397,15 +398,54 @@ func (wr writeRenderer) Render(v *toolCallCmp) string {
 //  Fetch renderer
 // -----------------------------------------------------------------------------
 
-// fetchRenderer handles URL fetching with format-specific content display
-type fetchRenderer struct {
+// simpleFetchRenderer handles URL fetching with format-specific content display
+type simpleFetchRenderer struct {
+	baseRenderer
+}
+
+// Render displays the fetched URL with format and timeout parameters
+func (fr simpleFetchRenderer) Render(v *toolCallCmp) string {
+	var params tools.FetchParams
+	var args []string
+	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
+		args = newParamBuilder().
+			addMain(params.URL).
+			addKeyValue("format", params.Format).
+			addKeyValue("timeout", formatTimeout(params.Timeout)).
+			build()
+	}
+
+	return fr.renderWithParams(v, "Fetch", args, func() string {
+		file := fr.getFileExtension(params.Format)
+		return renderCodeContent(v, file, v.result.Content, 0)
+	})
+}
+
+// getFileExtension returns appropriate file extension for syntax highlighting
+func (fr simpleFetchRenderer) getFileExtension(format string) string {
+	switch format {
+	case "text":
+		return "fetch.txt"
+	case "html":
+		return "fetch.html"
+	default:
+		return "fetch.md"
+	}
+}
+
+// -----------------------------------------------------------------------------
+//  Agentic fetch renderer
+// -----------------------------------------------------------------------------
+
+// agenticFetchRenderer handles URL fetching with prompt parameter and nested tool calls
+type agenticFetchRenderer struct {
 	baseRenderer
 }
 
 // Render displays the fetched URL with prompt parameter and nested tool calls
-func (fr fetchRenderer) Render(v *toolCallCmp) string {
+func (fr agenticFetchRenderer) Render(v *toolCallCmp) string {
 	t := styles.CurrentTheme()
-	var params tools.FetchParams
+	var params tools.AgenticFetchParams
 	var args []string
 	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
 		args = newParamBuilder().
@@ -416,7 +456,7 @@ func (fr fetchRenderer) Render(v *toolCallCmp) string {
 	prompt := params.Prompt
 	prompt = strings.ReplaceAll(prompt, "\n", " ")
 
-	header := fr.makeHeader(v, "Fetch", v.textWidth(), args...)
+	header := fr.makeHeader(v, "Agentic Fetch", v.textWidth(), args...)
 	if res, done := earlyState(header, v); v.cancelled && done {
 		return res
 	}
@@ -484,7 +524,7 @@ type webFetchRenderer struct {
 
 // Render displays a compact view of web_fetch with just the URL in a link style
 func (wfr webFetchRenderer) Render(v *toolCallCmp) string {
-	var params tools.FetchParams
+	var params tools.WebFetchParams
 	var args []string
 	if err := wfr.unmarshalParams(v.call.Input, &params); err == nil {
 		args = newParamBuilder().
@@ -979,6 +1019,8 @@ func prettifyToolName(name string) string {
 		return "Multi-Edit"
 	case tools.FetchToolName:
 		return "Fetch"
+	case tools.AgenticFetchToolName:
+		return "Agentic Fetch"
 	case tools.WebFetchToolName:
 		return "Fetching"
 	case tools.GlobToolName:

internal/tui/components/chat/messages/tool.go 🔗

@@ -286,6 +286,19 @@ func (m *toolCallCmp) formatParametersForCopy() string {
 		}
 	case tools.FetchToolName:
 		var params tools.FetchParams
+		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
+			var parts []string
+			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
+			if params.Format != "" {
+				parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
+			}
+			if params.Timeout > 0 {
+				parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
+			}
+			return strings.Join(parts, "\n")
+		}
+	case tools.AgenticFetchToolName:
+		var params tools.AgenticFetchParams
 		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
 			var parts []string
 			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
@@ -397,6 +410,8 @@ func (m *toolCallCmp) formatResultForCopy() string {
 		return m.formatWriteResultForCopy()
 	case tools.FetchToolName:
 		return m.formatFetchResultForCopy()
+	case tools.AgenticFetchToolName:
+		return m.formatAgenticFetchResultForCopy()
 	case tools.WebFetchToolName:
 		return m.formatWebFetchResultForCopy()
 	case agent.AgentToolName:
@@ -608,6 +623,29 @@ func (m *toolCallCmp) formatFetchResultForCopy() string {
 		return m.result.Content
 	}
 
+	var result strings.Builder
+	if params.URL != "" {
+		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+	}
+	if params.Format != "" {
+		result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
+	}
+	if params.Timeout > 0 {
+		result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
+	}
+	result.WriteString("\n")
+
+	result.WriteString(m.result.Content)
+
+	return result.String()
+}
+
+func (m *toolCallCmp) formatAgenticFetchResultForCopy() string {
+	var params tools.AgenticFetchParams
+	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
+		return m.result.Content
+	}
+
 	var result strings.Builder
 	if params.URL != "" {
 		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))

internal/tui/components/dialogs/permissions/permissions.go 🔗

@@ -373,6 +373,8 @@ func (p *permissionDialogCmp) renderHeader() string {
 		)
 	case tools.FetchToolName:
 		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
+	case tools.AgenticFetchToolName:
+		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
 	case tools.ViewToolName:
 		params := p.permission.Params.(tools.ViewPermissionsParams)
 		fileKey := t.S().Muted.Render("File")
@@ -427,6 +429,8 @@ func (p *permissionDialogCmp) getOrGenerateContent() string {
 		content = p.generateMultiEditContent()
 	case tools.FetchToolName:
 		content = p.generateFetchContent()
+	case tools.AgenticFetchToolName:
+		content = p.generateAgenticFetchContent()
 	case tools.ViewToolName:
 		content = p.generateViewContent()
 	case tools.LSToolName:
@@ -570,6 +574,20 @@ func (p *permissionDialogCmp) generateFetchContent() string {
 	return ""
 }
 
+func (p *permissionDialogCmp) generateAgenticFetchContent() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base.Background(t.BgSubtle)
+	if pr, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams); ok {
+		content := fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt)
+		finalContent := baseStyle.
+			Padding(1, 2).
+			Width(p.contentViewPort.Width()).
+			Render(content)
+		return finalContent
+	}
+	return ""
+}
+
 func (p *permissionDialogCmp) generateViewContent() string {
 	t := styles.CurrentTheme()
 	baseStyle := t.S().Base.Background(t.BgSubtle)
@@ -776,6 +794,9 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
 	case tools.FetchToolName:
 		p.width = int(float64(p.wWidth) * 0.8)
 		p.height = int(float64(p.wHeight) * 0.3)
+	case tools.AgenticFetchToolName:
+		p.width = int(float64(p.wWidth) * 0.8)
+		p.height = int(float64(p.wHeight) * 0.4)
 	case tools.ViewToolName:
 		p.width = int(float64(p.wWidth) * 0.8)
 		p.height = int(float64(p.wHeight) * 0.4)