feat: new fetch tool

Kujtim Hoxha created

uses a subagent for researching and finding content

Change summary

go.mod                                            |   2 
internal/agent/common_test.go                     |   1 
internal/agent/coordinator.go                     |   9 
internal/agent/fetch_tool.go                      | 209 +++++++++++++++++
internal/agent/templates/fetch.md                 |  34 ++
internal/agent/templates/fetch_prompt.md.tpl      |  45 +++
internal/agent/tools/fetch.go                     | 205 ----------------
internal/agent/tools/fetch.md                     |  28 --
internal/agent/tools/fetch_helpers.go             |  70 +++++
internal/agent/tools/fetch_types.go               |  27 ++
internal/agent/tools/web_fetch.go                 |  77 ++++++
internal/agent/tools/web_fetch.md                 |  28 ++
internal/config/config.go                         |  14 +
internal/config/load_test.go                      |   7 
internal/tui/components/chat/messages/messages.go |  33 +
internal/tui/components/chat/messages/renderer.go | 195 +++++++++++++--
internal/tui/components/chat/messages/tool.go     |  39 ++
internal/tui/styles/markdown.go                   | 185 +++++++++++++++
18 files changed, 924 insertions(+), 284 deletions(-)

Detailed changes

go.mod 🔗

@@ -6,7 +6,6 @@ require (
 	charm.land/fantasy v0.1.3
 	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
@@ -57,6 +56,7 @@ 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/common_test.go 🔗

@@ -179,7 +179,6 @@ func coderAgent(r *recorder.Recorder, env env, large, small fantasy.LanguageMode
 		tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
 		tools.NewEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
 		tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
-		tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
 		tools.NewGlobTool(env.workingDir),
 		tools.NewGrepTool(env.workingDir),
 		tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),

internal/agent/coordinator.go 🔗

@@ -321,12 +321,19 @@ 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 err != nil {
+			return nil, err
+		}
+		allTools = append(allTools, fetchTool)
+	}
+
 	allTools = append(allTools,
 		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution),
 		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),

internal/agent/fetch_tool.go 🔗

@@ -0,0 +1,209 @@
+package agent
+
+import (
+	"context"
+	_ "embed"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"os"
+	"time"
+
+	"charm.land/fantasy"
+
+	"github.com/charmbracelet/crush/internal/agent/prompt"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+//go:embed templates/fetch.md
+var fetchToolDescription []byte
+
+//go:embed templates/fetch_prompt.md.tpl
+var fetchPromptTmpl []byte
+
+func (c *coordinator) fetchTool(_ context.Context, client *http.Client) (fantasy.AgentTool, error) {
+	_, ok := c.cfg.Agents[config.AgentFetch]
+	if !ok {
+		return nil, errors.New("fetch agent not configured")
+	}
+
+	if client == nil {
+		client = &http.Client{
+			Timeout: 30 * time.Second,
+			Transport: &http.Transport{
+				MaxIdleConns:        100,
+				MaxIdleConnsPerHost: 10,
+				IdleConnTimeout:     90 * time.Second,
+			},
+		}
+	}
+
+	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
+			}
+
+			if params.URL == "" {
+				return fantasy.NewTextErrorResponse("url is required"), nil
+			}
+
+			if params.Prompt == "" {
+				return fantasy.NewTextErrorResponse("prompt is required"), nil
+			}
+
+			sessionID := tools.GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, errors.New("session id missing from context")
+			}
+
+			agentMessageID := tools.GetMessageFromContext(ctx)
+			if agentMessageID == "" {
+				return fantasy.ToolResponse{}, errors.New("agent message id missing from context")
+			}
+
+			p := c.permissions.Request(
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        c.cfg.WorkingDir(),
+					ToolCallID:  call.ID,
+					ToolName:    tools.FetchToolName,
+					Action:      "fetch",
+					Description: fmt.Sprintf("Fetch and analyze content from URL: %s", params.URL),
+					Params:      tools.FetchPermissionsParams(params),
+				},
+			)
+
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			content, err := tools.FetchURLAndConvert(ctx, client, params.URL)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to fetch URL: %s", err)), nil
+			}
+
+			tmpDir, err := os.MkdirTemp("", "crush-fetch-*")
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil
+			}
+			defer os.RemoveAll(tmpDir)
+
+			hasLargeContent := len(content) > tools.LargeContentThreshold
+			var fullPrompt string
+
+			if hasLargeContent {
+				tempFile, err := os.CreateTemp(tmpDir, "page-*.md")
+				if err != nil {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary file: %s", err)), nil
+				}
+				tempFilePath := tempFile.Name()
+
+				if _, err := tempFile.WriteString(content); err != nil {
+					tempFile.Close()
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to write content to file: %s", err)), nil
+				}
+				tempFile.Close()
+
+				fullPrompt = fmt.Sprintf("%s\n\nThe web page from %s has been saved to: %s\n\nUse the view and grep tools to analyze this file and extract the requested information.", params.Prompt, params.URL, tempFilePath)
+			} else {
+				fullPrompt = fmt.Sprintf("%s\n\nWeb page URL: %s\n\n<webpage_content>\n%s\n</webpage_content>", params.Prompt, params.URL, content)
+			}
+
+			promptOpts := []prompt.Option{
+				prompt.WithWorkingDir(tmpDir),
+			}
+
+			promptTemplate, err := prompt.NewPrompt("fetch", string(fetchPromptTmpl), promptOpts...)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error creating prompt: %s", err)
+			}
+
+			_, small, err := c.buildAgentModels(ctx)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err)
+			}
+
+			systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), *c.cfg)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
+			}
+
+			smallProviderCfg, ok := c.cfg.Providers.Get(small.ModelCfg.Provider)
+			if !ok {
+				return fantasy.ToolResponse{}, errors.New("small model provider not configured")
+			}
+
+			webFetchTool := tools.NewWebFetchTool(tmpDir, client)
+			fetchTools := []fantasy.AgentTool{
+				webFetchTool,
+				tools.NewGlobTool(tmpDir),
+				tools.NewGrepTool(tmpDir),
+				tools.NewViewTool(c.lspClients, c.permissions, tmpDir),
+			}
+
+			agent := NewSessionAgent(SessionAgentOptions{
+				LargeModel:           small, // Use small model for both (fetch doesn't need large)
+				SmallModel:           small,
+				SystemPromptPrefix:   smallProviderCfg.SystemPromptPrefix,
+				SystemPrompt:         systemPrompt,
+				DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
+				IsYolo:               c.permissions.SkipRequests(),
+				Sessions:             c.sessions,
+				Messages:             c.messages,
+				Tools:                fetchTools,
+			})
+
+			agentToolSessionID := c.sessions.CreateAgentToolSessionID(agentMessageID, call.ID)
+			session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, sessionID, "Fetch Analysis")
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
+			}
+
+			c.permissions.AutoApproveSession(session.ID)
+
+			// Use small model for web content analysis (faster and cheaper)
+			maxTokens := small.CatwalkCfg.DefaultMaxTokens
+			if small.ModelCfg.MaxTokens != 0 {
+				maxTokens = small.ModelCfg.MaxTokens
+			}
+
+			result, err := agent.Run(ctx, SessionAgentCall{
+				SessionID:        session.ID,
+				Prompt:           fullPrompt,
+				MaxOutputTokens:  maxTokens,
+				ProviderOptions:  getProviderOptions(small, smallProviderCfg),
+				Temperature:      small.ModelCfg.Temperature,
+				TopP:             small.ModelCfg.TopP,
+				TopK:             small.ModelCfg.TopK,
+				FrequencyPenalty: small.ModelCfg.FrequencyPenalty,
+				PresencePenalty:  small.ModelCfg.PresencePenalty,
+			})
+			if err != nil {
+				return fantasy.NewTextErrorResponse("error generating response"), nil
+			}
+
+			updatedSession, err := c.sessions.Get(ctx, session.ID)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
+			}
+			parentSession, err := c.sessions.Get(ctx, sessionID)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err)
+			}
+
+			parentSession.Cost += updatedSession.Cost
+
+			_, err = c.sessions.Save(ctx, parentSession)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
+			}
+
+			return fantasy.NewTextResponse(result.Response.Content.Text()), nil
+		}), nil
+}

internal/agent/templates/fetch.md 🔗

@@ -0,0 +1,34 @@
+Fetches content from a specified URL and processes it using an AI model.
+
+<usage>
+- Takes a URL and a prompt as input
+- Fetches the URL content, converts HTML to markdown
+- Processes the content with the prompt using a small, fast model
+- Returns the model's response about the content
+- Use this tool when you need to retrieve and analyze web content
+</usage>
+
+<usage_notes>
+
+- IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp_".
+- The URL must be a fully-formed valid URL
+- HTTP URLs will be automatically upgraded to HTTPS
+- The prompt should describe what information you want to extract from the page
+- This tool is read-only and does not modify any files
+- 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.
+  </usage_notes>
+
+<limitations>
+- Max response size: 5MB
+- Only supports HTTP and HTTPS protocols
+- Cannot handle authentication or cookies
+- Some websites may block automated requests
+</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
+</tips>

internal/agent/templates/fetch_prompt.md.tpl 🔗

@@ -0,0 +1,45 @@
+You are a web content analysis agent for Crush. Your task is to analyze web page content and extract the information requested by the user.
+
+<rules>
+1. You should be concise and direct in your responses
+2. Focus only on the information requested in the user's prompt
+3. If the content is provided in a file path, use the grep and view tools to efficiently search through it
+4. When relevant, quote specific sections from the page to support your answer
+5. If the requested information is not found, clearly state that
+6. Any file paths you use MUST be absolute
+7. **IMPORTANT**: If you need information from a linked page to answer the question, use the web_fetch tool to follow that link
+8. After fetching a link, analyze the content yourself to extract what's needed
+9. Don't hesitate to follow multiple links if necessary to get complete information
+10. **CRITICAL**: At the end of your response, include a "Sources" section listing ALL URLs that were useful in answering the question
+</rules>
+
+<response_format>
+Your response should be structured as follows:
+
+[Your answer to the user's question]
+
+## Sources
+- [URL 1 that was useful]
+- [URL 2 that was useful]
+- [URL 3 that was useful]
+...
+
+Only include URLs that actually contributed information to your answer. The main URL is always included. Add any additional URLs you fetched that provided relevant information.
+</response_format>
+
+<env>
+Working directory: {{.WorkingDir}}
+Platform: {{.Platform}}
+Today's date: {{.Date}}
+</env>
+
+<web_fetch_tool>
+You have access to a web_fetch tool that allows you to fetch additional web pages:
+- Use it when you need to follow links from the current page
+- Provide just the URL (no prompt parameter)
+- The tool will fetch and return the content (or save to a file if large)
+- YOU must then analyze that content to answer the user's question
+- **Use this liberally** - if a link seems relevant to answering the question, fetch it!
+- You can fetch multiple pages in sequence to gather all needed information
+- Remember to include any fetched URLs in your Sources section if they were helpful
+</web_fetch_tool>

internal/agent/tools/fetch.go 🔗

@@ -1,205 +0,0 @@
-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 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)"`
-}
-
-type FetchPermissionsParams struct {
-	URL     string `json:"url"`
-	Format  string `json:"format"`
-	Timeout int    `json:"timeout,omitempty"`
-}
-
-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 🔗

@@ -1,28 +0,0 @@
-Fetches content from URL and returns it in specified format.
-
-<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
-- 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
-</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
-</tips>

internal/agent/tools/fetch_helpers.go 🔗

@@ -0,0 +1,70 @@
+package tools
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"unicode/utf8"
+
+	md "github.com/JohannesKaufmann/html-to-markdown"
+)
+
+// FetchURLAndConvert fetches a URL and converts HTML content to markdown.
+func FetchURLAndConvert(ctx context.Context, client *http.Client, url string) (string, error) {
+	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+	if err != nil {
+		return "", 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 "", fmt.Errorf("failed to fetch URL: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("request failed with status code: %d", resp.StatusCode)
+	}
+
+	maxSize := int64(5 * 1024 * 1024) // 5MB
+	body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
+	if err != nil {
+		return "", fmt.Errorf("failed to read response body: %w", err)
+	}
+
+	content := string(body)
+
+	if !utf8.ValidString(content) {
+		return "", errors.New("response content is not valid UTF-8")
+	}
+
+	contentType := resp.Header.Get("Content-Type")
+
+	// Convert HTML to markdown for better AI processing.
+	if strings.Contains(contentType, "text/html") {
+		markdown, err := ConvertHTMLToMarkdown(content)
+		if err != nil {
+			return "", fmt.Errorf("failed to convert HTML to markdown: %w", err)
+		}
+		content = markdown
+	}
+
+	return content, nil
+}
+
+// ConvertHTMLToMarkdown converts HTML content to markdown format.
+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_types.go 🔗

@@ -0,0 +1,27 @@
+package tools
+
+// FetchToolName is the name of the fetch tool.
+const FetchToolName = "fetch"
+
+// WebFetchToolName is the name of the web_fetch tool.
+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 {
+	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 {
+	URL    string `json:"url"`
+	Prompt string `json:"prompt"`
+}
+
+// WebFetchParams defines the parameters for the web_fetch tool.
+type WebFetchParams struct {
+	URL string `json:"url" description:"The URL to fetch content from"`
+}

internal/agent/tools/web_fetch.go 🔗

@@ -0,0 +1,77 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"os"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+)
+
+//go:embed web_fetch.md
+var webFetchToolDescription []byte
+
+// NewWebFetchTool creates a simple web fetch tool for sub-agents (no permissions needed).
+func NewWebFetchTool(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(
+		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
+			}
+
+			content, err := FetchURLAndConvert(ctx, client, params.URL)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to fetch URL: %s", err)), nil
+			}
+
+			hasLargeContent := len(content) > LargeContentThreshold
+			var result strings.Builder
+
+			if hasLargeContent {
+				tempFile, err := os.CreateTemp(workingDir, "page-*.md")
+				if err != nil {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary file: %s", err)), nil
+				}
+				tempFilePath := tempFile.Name()
+
+				if _, err := tempFile.WriteString(content); err != nil {
+					_ = tempFile.Close() // Best effort close
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to write content to file: %s", err)), nil
+				}
+				if err := tempFile.Close(); err != nil {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to close temporary file: %s", err)), nil
+				}
+
+				result.WriteString(fmt.Sprintf("Fetched content from %s (large page)\n\n", params.URL))
+				result.WriteString(fmt.Sprintf("Content saved to: %s\n\n", tempFilePath))
+				result.WriteString("Use the view and grep tools to analyze this file.")
+			} else {
+				result.WriteString(fmt.Sprintf("Fetched content from %s:\n\n", params.URL))
+				result.WriteString(content)
+			}
+
+			return fantasy.NewTextResponse(result.String()), nil
+		})
+}

internal/agent/tools/web_fetch.md 🔗

@@ -0,0 +1,28 @@
+Fetches content from a web URL (for use by sub-agents).
+
+<usage>
+- Provide a URL to fetch
+- The tool fetches the content and returns it as markdown
+- Use this when you need to follow links from the current page
+- After fetching, analyze the content to answer the user's question
+</usage>
+
+<features>
+- Automatically converts HTML to markdown for easier analysis
+- For large pages (>50KB), saves content to a temporary file and provides the path
+- You can then use grep/view tools to search through the file
+- Handles UTF-8 content validation
+</features>
+
+<limitations>
+- Max response size: 5MB
+- Only supports HTTP and HTTPS protocols
+- Cannot handle authentication or cookies
+- Some websites may block automated requests
+</limitations>
+
+<tips>
+- For large pages saved to files, use grep to find relevant sections first
+- Don't fetch unnecessary pages - only when needed to answer the question
+- Focus on extracting specific information from the fetched content
+</tips>

internal/config/config.go 🔗

@@ -51,6 +51,7 @@ const (
 const (
 	AgentCoder string = "coder"
 	AgentTask  string = "task"
+	AgentFetch string = "fetch"
 )
 
 type SelectedModel struct {
@@ -533,6 +534,19 @@ func (c *Config) SetupAgents() {
 			// NO MCPs or LSPs by default
 			AllowedMCP: map[string][]string{},
 		},
+
+		AgentFetch: {
+			ID:          AgentFetch,
+			Name:        "Fetch",
+			Description: "An agent that fetches and analyzes web content.",
+			Model:       SelectedModelTypeSmall,
+			// No context paths - fetch agent operates in isolated environment
+			ContextPaths: []string{},
+			// Only allow tools needed for web content analysis
+			AllowedTools: []string{"web_fetch", "grep", "glob", "view"},
+			// NO MCPs or LSPs
+			AllowedMCP: map[string][]string{},
+		},
 	}
 	c.Agents = agents
 }

internal/config/load_test.go 🔗

@@ -469,6 +469,13 @@ func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
 	assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
+
+	fetchAgent, ok := cfg.Agents[AgentFetch]
+	require.True(t, ok)
+	assert.Equal(t, "fetch", fetchAgent.ID)
+	assert.Equal(t, SelectedModelTypeSmall, fetchAgent.Model)
+	assert.Equal(t, []string{"web_fetch", "grep", "glob", "view"}, fetchAgent.AllowedTools)
+	assert.Empty(t, fetchAgent.ContextPaths)
 }
 
 func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {

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

@@ -262,19 +262,29 @@ func (m *messageCmp) renderThinkingContent() string {
 	if strings.TrimSpace(reasoningContent.Thinking) == "" {
 		return ""
 	}
-	lines := strings.Split(reasoningContent.Thinking, "\n")
-	var content strings.Builder
-	lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
-	for i, line := range lines {
-		if line == "" {
-			continue
-		}
-		content.WriteString(lineStyle.Width(m.textWidth() - 2).Render(line))
-		if i < len(lines)-1 {
-			content.WriteString("\n")
+
+	width := m.textWidth() - 2
+	width = min(width, 120)
+
+	renderer := styles.GetPlainMarkdownRenderer(width - 1)
+	rendered, err := renderer.Render(reasoningContent.Thinking)
+	if err != nil {
+		lines := strings.Split(reasoningContent.Thinking, "\n")
+		var content strings.Builder
+		lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
+		for i, line := range lines {
+			if line == "" {
+				continue
+			}
+			content.WriteString(lineStyle.Width(width).Render(line))
+			if i < len(lines)-1 {
+				content.WriteString("\n")
+			}
 		}
+		rendered = content.String()
 	}
-	fullContent := content.String()
+
+	fullContent := strings.TrimSpace(rendered)
 	height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10)
 	m.thinkingViewport.SetHeight(height)
 	m.thinkingViewport.SetWidth(m.textWidth())
@@ -299,6 +309,7 @@ func (m *messageCmp) renderThinkingContent() string {
 			footer = m.anim.View()
 		}
 	}
+	lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
 	return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
 }
 

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

@@ -169,6 +169,7 @@ func init() {
 	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.WebFetchToolName, func() renderer { return webFetchRenderer{} })
 	registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
 	registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
 	registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
@@ -401,34 +402,88 @@ type fetchRenderer struct {
 	baseRenderer
 }
 
-// Render displays the fetched URL with format and timeout parameters
+// Render displays the fetched URL with prompt parameter and nested tool calls
 func (fr fetchRenderer) Render(v *toolCallCmp) string {
+	t := styles.CurrentTheme()
 	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()
+	fr.unmarshalParams(v.call.Input, &params)
+
+	prompt := params.Prompt
+	prompt = strings.ReplaceAll(prompt, "\n", " ")
+
+	header := fr.makeHeader(v, "Fetch", v.textWidth())
+
+	// Check for error or cancelled states
+	if v.result.IsError {
+		message := v.renderToolError()
+		message = t.S().Base.PaddingLeft(2).Render(message)
+		return lipgloss.JoinVertical(lipgloss.Left, header, "", message)
+	}
+	if v.cancelled {
+		message := t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
+		message = t.S().Base.PaddingLeft(2).Render(message)
+		return lipgloss.JoinVertical(lipgloss.Left, header, "", message)
 	}
 
-	return fr.renderWithParams(v, "Fetch", args, func() string {
-		file := fr.getFileExtension(params.Format)
-		return renderCodeContent(v, file, v.result.Content, 0)
-	})
-}
+	if v.result.ToolCallID == "" && v.permissionRequested && !v.permissionGranted {
+		message := t.S().Base.Foreground(t.FgSubtle).Render("Requesting for permission...")
+		message = t.S().Base.PaddingLeft(2).Render(message)
+		return lipgloss.JoinVertical(lipgloss.Left, header, "", message)
+	}
 
-// getFileExtension returns appropriate file extension for syntax highlighting
-func (fr fetchRenderer) getFileExtension(format string) string {
-	switch format {
-	case "text":
-		return "fetch.txt"
-	case "html":
-		return "fetch.html"
-	default:
-		return "fetch.md"
+	// Show URL and prompt like agent tool shows task
+	urlTag := t.S().Base.Padding(0, 1).MarginLeft(1).Background(t.BlueLight).Foreground(t.White).Render("URL")
+	promptTag := t.S().Base.Padding(0, 1).MarginLeft(1).Background(t.Green).Foreground(t.White).Render("Prompt")
+
+	// Calculate left gutter width (icon + spacing)
+	leftGutterWidth := lipgloss.Width(urlTag) + 2 // +2 for " " spacing
+
+	// Cap at 120 cols minus left gutter
+	maxTextWidth := 120 - leftGutterWidth
+	if v.textWidth()-leftGutterWidth < maxTextWidth {
+		maxTextWidth = v.textWidth() - leftGutterWidth
 	}
+
+	urlText := t.S().Muted.Width(maxTextWidth).Render(params.URL)
+	promptText := t.S().Muted.Width(maxTextWidth).Render(prompt)
+
+	header = lipgloss.JoinVertical(
+		lipgloss.Left,
+		header,
+		"",
+		lipgloss.JoinHorizontal(lipgloss.Left, urlTag, " ", urlText),
+		"",
+		lipgloss.JoinHorizontal(lipgloss.Left, promptTag, " ", promptText),
+	)
+
+	// Show nested tool calls (from sub-agent) in a tree
+	childTools := tree.Root(header)
+	for _, call := range v.nestedToolCalls {
+		childTools.Child(call.View())
+	}
+
+	parts := []string{
+		childTools.Enumerator(RoundedEnumerator).String(),
+	}
+
+	if v.result.ToolCallID == "" {
+		v.spinning = true
+		parts = append(parts, "", v.anim.View())
+	} else {
+		v.spinning = false
+	}
+
+	header = lipgloss.JoinVertical(
+		lipgloss.Left,
+		parts...,
+	)
+
+	if v.result.ToolCallID == "" {
+		return header
+	}
+
+	body := renderMarkdownContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
 }
 
 // formatTimeout converts timeout seconds to duration string
@@ -439,6 +494,52 @@ func formatTimeout(timeout int) string {
 	return (time.Duration(timeout) * time.Second).String()
 }
 
+// -----------------------------------------------------------------------------
+//  Web fetch renderer
+// -----------------------------------------------------------------------------
+
+// webFetchRenderer handles web page fetching with simplified URL display
+type webFetchRenderer struct {
+	baseRenderer
+}
+
+// Render displays a compact view of web_fetch with just the URL in a link style
+func (wfr webFetchRenderer) Render(v *toolCallCmp) string {
+	t := styles.CurrentTheme()
+	var params tools.WebFetchParams
+	wfr.unmarshalParams(v.call.Input, &params)
+
+	width := v.textWidth()
+	if v.isNested {
+		width -= 4 // Adjust for nested tool call indentation
+	}
+
+	header := wfr.makeHeader(v, "Fetching", width)
+	if res, done := earlyState(header, v); v.cancelled && done {
+		return res
+	}
+
+	// Display URL in a subtle, link-like style
+	urlStyle := t.S().Muted.Foreground(t.Blue).Underline(true)
+	urlText := urlStyle.Render(params.URL)
+
+	header = lipgloss.JoinHorizontal(lipgloss.Left, header, " ", urlText)
+
+	// If nested, return header only (no body content)
+	if v.isNested {
+		return v.style().Render(header)
+	}
+
+	if v.result.ToolCallID == "" {
+		v.spinning = true
+		return lipgloss.JoinHorizontal(lipgloss.Left, header, " ", v.anim.View())
+	}
+
+	v.spinning = false
+	body := renderMarkdownContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
+}
+
 // -----------------------------------------------------------------------------
 //  Download renderer
 // -----------------------------------------------------------------------------
@@ -619,7 +720,8 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
 		return res
 	}
 	taskTag := t.S().Base.Padding(0, 1).MarginLeft(1).Background(t.BlueLight).Foreground(t.White).Render("Task")
-	remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2 // -2 for padding
+	remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
+	remainingWidth = min(remainingWidth, 120-lipgloss.Width(header)-lipgloss.Width(taskTag)-2)
 	prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
 	header = lipgloss.JoinVertical(
 		lipgloss.Left,
@@ -657,7 +759,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
 		return header
 	}
 
-	body := renderPlainContent(v, v.result.Content)
+	body := renderMarkdownContent(v, v.result.Content)
 	return joinHeaderBody(header, body)
 }
 
@@ -753,13 +855,56 @@ func renderPlainContent(v *toolCallCmp, content string) string {
 	content = strings.TrimSpace(content)
 	lines := strings.Split(content, "\n")
 
-	width := v.textWidth() - 2 // -2 for left padding
+	width := v.textWidth() - 2
 	var out []string
 	for i, ln := range lines {
 		if i >= responseContextHeight {
 			break
 		}
 		ln = ansiext.Escape(ln)
+		ln = " " + ln
+		if len(ln) > width {
+			ln = v.fit(ln, width)
+		}
+		out = append(out, t.S().Muted.
+			Width(width).
+			Background(t.BgBaseLighter).
+			Render(ln))
+	}
+
+	if len(lines) > responseContextHeight {
+		out = append(out, t.S().Muted.
+			Background(t.BgBaseLighter).
+			Width(width).
+			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
+	}
+
+	return strings.Join(out, "\n")
+}
+
+func renderMarkdownContent(v *toolCallCmp, content string) string {
+	t := styles.CurrentTheme()
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	content = strings.ReplaceAll(content, "\t", "    ")
+	content = strings.TrimSpace(content)
+
+	width := v.textWidth() - 2
+	width = min(width, 120)
+
+	renderer := styles.GetPlainMarkdownRenderer(width - 1)
+	rendered, err := renderer.Render(content)
+	if err != nil {
+		return renderPlainContent(v, content)
+	}
+
+	rendered = strings.TrimSpace(rendered)
+	lines := strings.Split(rendered, "\n")
+
+	var out []string
+	for i, ln := range lines {
+		if i >= responseContextHeight {
+			break
+		}
 		ln = " " + ln // left padding
 		if len(ln) > width {
 			ln = v.fit(ln, width)

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

@@ -289,14 +289,16 @@ func (m *toolCallCmp) formatParametersForCopy() string {
 		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:** %s", (time.Duration(params.Timeout)*time.Second).String()))
+			if params.Prompt != "" {
+				parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
 			}
 			return strings.Join(parts, "\n")
 		}
+	case tools.WebFetchToolName:
+		var params tools.WebFetchParams
+		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
+			return fmt.Sprintf("**URL:** %s", params.URL)
+		}
 	case tools.GrepToolName:
 		var params tools.GrepParams
 		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
@@ -395,6 +397,8 @@ func (m *toolCallCmp) formatResultForCopy() string {
 		return m.formatWriteResultForCopy()
 	case tools.FetchToolName:
 		return m.formatFetchResultForCopy()
+	case tools.WebFetchToolName:
+		return m.formatWebFetchResultForCopy()
 	case agent.AgentToolName:
 		return m.formatAgentResultForCopy()
 	case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName:
@@ -608,15 +612,26 @@ func (m *toolCallCmp) formatFetchResultForCopy() string {
 	if params.URL != "" {
 		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
 	}
+	if params.Prompt != "" {
+		result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
+	}
 
-	switch params.Format {
-	case "html":
-		result.WriteString("```html\n")
-	case "text":
-		result.WriteString("```\n")
-	default: // markdown
-		result.WriteString("```markdown\n")
+	result.WriteString("```markdown\n")
+	result.WriteString(m.result.Content)
+	result.WriteString("\n```")
+
+	return result.String()
+}
+
+func (m *toolCallCmp) formatWebFetchResultForCopy() string {
+	var params tools.WebFetchParams
+	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
+		return m.result.Content
 	}
+
+	var result strings.Builder
+	result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
+	result.WriteString("```markdown\n")
 	result.WriteString(m.result.Content)
 	result.WriteString("\n```")
 

internal/tui/styles/markdown.go 🔗

@@ -1,9 +1,19 @@
 package styles
 
 import (
+	"fmt"
+	"image/color"
+
 	"github.com/charmbracelet/glamour/v2"
+	"github.com/charmbracelet/glamour/v2/ansi"
 )
 
+// lipglossColorToHex converts a color.Color to hex string
+func lipglossColorToHex(c color.Color) string {
+	r, g, b, _ := c.RGBA()
+	return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
+}
+
 // Helper functions for style pointers
 func boolPtr(b bool) *bool       { return &b }
 func stringPtr(s string) *string { return &s }
@@ -18,3 +28,178 @@ func GetMarkdownRenderer(width int) *glamour.TermRenderer {
 	)
 	return r
 }
+
+// returns a glamour TermRenderer with no colors (plain text with structure)
+func GetPlainMarkdownRenderer(width int) *glamour.TermRenderer {
+	r, _ := glamour.NewTermRenderer(
+		glamour.WithStyles(PlainMarkdownStyle()),
+		glamour.WithWordWrap(width),
+	)
+	return r
+}
+
+// PlainMarkdownStyle returns a glamour style config with no colors
+func PlainMarkdownStyle() ansi.StyleConfig {
+	t := CurrentTheme()
+	bgColor := stringPtr(lipglossColorToHex(t.BgBaseLighter))
+	fgColor := stringPtr(lipglossColorToHex(t.FgMuted))
+	return ansi.StyleConfig{
+		Document: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		BlockQuote: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+			Indent:      uintPtr(1),
+			IndentToken: stringPtr("│ "),
+		},
+		List: ansi.StyleList{
+			LevelIndent: defaultListIndent,
+		},
+		Heading: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				BlockSuffix:     "\n",
+				Bold:            boolPtr(true),
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		H1: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Bold:            boolPtr(true),
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		H2: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "## ",
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		H3: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "### ",
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		H4: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "#### ",
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		H5: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "##### ",
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		H6: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "###### ",
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		Strikethrough: ansi.StylePrimitive{
+			CrossedOut:      boolPtr(true),
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Emph: ansi.StylePrimitive{
+			Italic:          boolPtr(true),
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Strong: ansi.StylePrimitive{
+			Bold:            boolPtr(true),
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		HorizontalRule: ansi.StylePrimitive{
+			Format:          "\n--------\n",
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Item: ansi.StylePrimitive{
+			BlockPrefix:     "• ",
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Enumeration: ansi.StylePrimitive{
+			BlockPrefix:     ". ",
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Task: ansi.StyleTask{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+			Ticked:   "[✓] ",
+			Unticked: "[ ] ",
+		},
+		Link: ansi.StylePrimitive{
+			Underline:       boolPtr(true),
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		LinkText: ansi.StylePrimitive{
+			Bold:            boolPtr(true),
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Image: ansi.StylePrimitive{
+			Underline:       boolPtr(true),
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		ImageText: ansi.StylePrimitive{
+			Format:          "Image: {{.text}} →",
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Code: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		CodeBlock: ansi.StyleCodeBlock{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color:           fgColor,
+					BackgroundColor: bgColor,
+				},
+				Margin: uintPtr(defaultMargin),
+			},
+		},
+		Table: ansi.StyleTable{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color:           fgColor,
+					BackgroundColor: bgColor,
+				},
+			},
+		},
+		DefinitionDescription: ansi.StylePrimitive{
+			BlockPrefix:     "\n ",
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+	}
+}