diff --git a/go.mod b/go.mod
index b70c2dfc95387dcf70ea39bf72a4f64e26cd73cc..378e0486af06e8d46368e1e881b8f2f4dc35f243 100644
--- a/go.mod
+++ b/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
diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go
index 3e93181325091a1a91cd8a3e661194f11d38ee3f..b937c20dd510eb9dff52bd348bf7baf21aafcf8c 100644
--- a/internal/agent/agent_tool.go
+++ b/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), ¶ms); err != nil {
- return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
- }
if params.Prompt == "" {
return fantasy.NewTextErrorResponse("prompt is required"), nil
}
diff --git a/internal/agent/fetch_tool.go b/internal/agent/agentic_fetch_tool.go
similarity index 88%
rename from internal/agent/fetch_tool.go
rename to internal/agent/agentic_fetch_tool.go
index ada9d375f6c610811772961b54556bfcd034ca9e..102ee8101a1afa08bc08bda39821c2d53eb91b71 100644
--- a/internal/agent/fetch_tool.go
+++ b/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), ¶ms); 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)
}
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index 6c0e2752a56cc90a9bdf24d6c22cbe9eb0a26d48..367a7e67a2ea7fdf732984aa8d5774bf04752a8a 100644
--- a/internal/agent/coordinator.go
+++ b/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)
diff --git a/internal/agent/templates/fetch.md b/internal/agent/templates/agentic_fetch.md
similarity index 65%
rename from internal/agent/templates/fetch.md
rename to internal/agent/templates/agentic_fetch.md
index 97c764157878b8f2b058ff7691f7c21e39a8f969..828212907fb9b0894531c096afa9ed73e3cd8824 100644
--- a/internal/agent/templates/fetch.md
+++ b/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.
+
+
+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)
+
- 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
@@ -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
- 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
diff --git a/internal/agent/templates/fetch_prompt.md.tpl b/internal/agent/templates/agentic_fetch_prompt.md.tpl
similarity index 100%
rename from internal/agent/templates/fetch_prompt.md.tpl
rename to internal/agent/templates/agentic_fetch_prompt.md.tpl
diff --git a/internal/agent/tools/fetch.go b/internal/agent/tools/fetch.go
new file mode 100644
index 0000000000000000000000000000000000000000..dd250398190fff554a62695912bd0bc7ed76a600
--- /dev/null
+++ b/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 = "\n\n" + body + "\n\n"
+ }
+ }
+ // 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
+}
diff --git a/internal/agent/tools/fetch.md b/internal/agent/tools/fetch.md
new file mode 100644
index 0000000000000000000000000000000000000000..af96a0367d8cadceec8db34e791e3a5d9608d8e0
--- /dev/null
+++ b/internal/agent/tools/fetch.md
@@ -0,0 +1,45 @@
+Fetches raw content from URL and returns it in specified format without any AI processing.
+
+
+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)
+
+
+
+- Provide URL to fetch content from
+- Specify desired output format (text, markdown, or html)
+- Optional timeout for request
+
+
+
+- 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
+
+
+
+- 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
+
+
+
+- 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
+
diff --git a/internal/agent/tools/fetch_helpers.go b/internal/agent/tools/fetch_helpers.go
index 8bdd7f8f8e12121b5a0dd6f59603bb4726fdce4c..35bf0e0de3e7c9307ac920f1d56b480fde8b863a 100644
--- a/internal/agent/tools/fetch_helpers.go
+++ b/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
+}
diff --git a/internal/agent/tools/fetch_types.go b/internal/agent/tools/fetch_types.go
index 05e555cb9a9d4a2a03bf3857b73e084db6403e73..54754c286ae58a4de9caad1be253ddc2fd3d5377 100644
--- a/internal/agent/tools/fetch_types.go
+++ b/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"`
+}
diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go
index cde12c2a76f49e08e38ecb26ea1c9d707c83a597..39a2e9d1dbf9e7fcf63c445f9a393f5cc24b88f3 100644
--- a/internal/agent/tools/view.go
+++ b/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,
}
}
diff --git a/internal/agent/tools/web_fetch.go b/internal/agent/tools/web_fetch.go
index 27aabd298be2a615daea94dc15d4cba22233d592..42a5f9282bf7a04807be208fb9d69e3beebaaa99 100644
--- a/internal/agent/tools/web_fetch.go
+++ b/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), ¶ms); err != nil {
- return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
- }
-
if params.URL == "" {
return fantasy.NewTextErrorResponse("url is required"), nil
}
diff --git a/internal/config/config.go b/internal/config/config.go
index 99c70079223127c71c3768da40d4aec0b55d62c7..acca8b62c8000e69a64a5fe41b4f756f2af1d8c4 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -475,6 +475,7 @@ func allToolNames() []string {
"lsp_diagnostics",
"lsp_references",
"fetch",
+ "agentic_fetch",
"glob",
"grep",
"ls",
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index 0b582489851ecdb494b2af55d2643e9d3602b165..7647a9080aced65ae5b1ca47be60a2d15a16c3d7 100644
--- a/internal/tui/components/chat/chat.go
+++ b/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)
diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go
index c06110eb23aaac6e5488b6c953615850992586e2..f8873a726ab23800fa7102df1cd8fae7cf6294c9 100644
--- a/internal/tui/components/chat/messages/renderer.go
+++ b/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, ¶ms); 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, ¶ms); 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, ¶ms); 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:
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index aa50f604d2ecf73bd1d27f704dc86b9655577608..c43f7f4a4a3a2c10f2a496470e5b7942c9990205 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/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), ¶ms) == 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), ¶ms) == 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), ¶ms) != nil {
+ return m.result.Content
+ }
+
var result strings.Builder
if params.URL != "" {
result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go
index 4ba4a1eda6df887acbee62fd18bfbeec0ffae134..a142b97d14ee765c44399974953572a34fbe1c19 100644
--- a/internal/tui/components/dialogs/permissions/permissions.go
+++ b/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)