Detailed changes
@@ -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
@@ -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),
@@ -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),
@@ -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), ¶ms); 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
+}
@@ -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>
@@ -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>
@@ -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
-}
@@ -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>
@@ -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
+}
@@ -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"`
+}
@@ -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), ¶ms); 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
+ })
+}
@@ -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>
@@ -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
}
@@ -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) {
@@ -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
}
@@ -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, ¶ms); err == nil {
- args = newParamBuilder().
- addMain(params.URL).
- addKeyValue("format", params.Format).
- addKeyValue("timeout", formatTimeout(params.Timeout)).
- build()
+ fr.unmarshalParams(v.call.Input, ¶ms)
+
+ 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, ¶ms)
+
+ 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)
@@ -289,14 +289,16 @@ func (m *toolCallCmp) formatParametersForCopy() string {
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:** %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), ¶ms) == nil {
+ return fmt.Sprintf("**URL:** %s", params.URL)
+ }
case tools.GrepToolName:
var params tools.GrepParams
if json.Unmarshal([]byte(m.call.Input), ¶ms) == 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), ¶ms) != 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```")
@@ -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,
+ },
+ }
+}