From ca7ee04414df155a3cf30ce17288952ffcd7bfc6 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 28 Oct 2025 14:56:44 +0100 Subject: [PATCH] feat: new fetch tool uses a subagent for researching and finding content --- 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 + .../tui/components/chat/messages/messages.go | 33 ++- .../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(-) create mode 100644 internal/agent/fetch_tool.go create mode 100644 internal/agent/templates/fetch.md create mode 100644 internal/agent/templates/fetch_prompt.md.tpl delete mode 100644 internal/agent/tools/fetch.go delete mode 100644 internal/agent/tools/fetch.md create mode 100644 internal/agent/tools/fetch_helpers.go create mode 100644 internal/agent/tools/fetch_types.go create mode 100644 internal/agent/tools/web_fetch.go create mode 100644 internal/agent/tools/web_fetch.md diff --git a/go.mod b/go.mod index ceb1e21833055d3d8e4d5163d0d6c80af561f62c..4c6dc6b604c357e07e732c616d57aee2fb5a35a7 100644 --- a/go.mod +++ b/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 diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index f6f564109a32c278f6e127809b9b2ef550c239bd..21bdaf1ab501d34a4ac69cc592af7787733e0950 100644 --- a/internal/agent/common_test.go +++ b/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), diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index e6cdc70d9294dc2ef60ae31582490f4220f52620..6c0e2752a56cc90a9bdf24d6c22cbe9eb0a26d48 100644 --- a/internal/agent/coordinator.go +++ b/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), diff --git a/internal/agent/fetch_tool.go b/internal/agent/fetch_tool.go new file mode 100644 index 0000000000000000000000000000000000000000..e9b81a55bd8c9d71f856426cddd0585a5e7d0bf5 --- /dev/null +++ b/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), ¶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\n%s\n", 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 +} diff --git a/internal/agent/templates/fetch.md b/internal/agent/templates/fetch.md new file mode 100644 index 0000000000000000000000000000000000000000..97c764157878b8f2b058ff7691f7c21e39a8f969 --- /dev/null +++ b/internal/agent/templates/fetch.md @@ -0,0 +1,34 @@ +Fetches content from a specified URL and processes it using an AI model. + + +- 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 + + + + +- 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. + + + +- Max response size: 5MB +- Only supports HTTP and HTTPS protocols +- Cannot handle authentication or cookies +- Some websites may block automated requests + + + +- 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 + diff --git a/internal/agent/templates/fetch_prompt.md.tpl b/internal/agent/templates/fetch_prompt.md.tpl new file mode 100644 index 0000000000000000000000000000000000000000..18e27a9bd6ba9913a58922e9deed0c1cdc2ce320 --- /dev/null +++ b/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. + + +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 + + + +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. + + + +Working directory: {{.WorkingDir}} +Platform: {{.Platform}} +Today's date: {{.Date}} + + + +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 + diff --git a/internal/agent/tools/fetch.go b/internal/agent/tools/fetch.go deleted file mode 100644 index 0701bd2be151213f2fd0e313726d03a5c35833a3..0000000000000000000000000000000000000000 --- a/internal/agent/tools/fetch.go +++ /dev/null @@ -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 = "\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 deleted file mode 100644 index 335e843aba10953d7413ed4345318f6220f58e09..0000000000000000000000000000000000000000 --- a/internal/agent/tools/fetch.md +++ /dev/null @@ -1,28 +0,0 @@ -Fetches content from URL and returns it in specified format. - - -- 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 -- 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 - - - -- 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 - diff --git a/internal/agent/tools/fetch_helpers.go b/internal/agent/tools/fetch_helpers.go new file mode 100644 index 0000000000000000000000000000000000000000..8bdd7f8f8e12121b5a0dd6f59603bb4726fdce4c --- /dev/null +++ b/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 +} diff --git a/internal/agent/tools/fetch_types.go b/internal/agent/tools/fetch_types.go new file mode 100644 index 0000000000000000000000000000000000000000..05e555cb9a9d4a2a03bf3857b73e084db6403e73 --- /dev/null +++ b/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"` +} diff --git a/internal/agent/tools/web_fetch.go b/internal/agent/tools/web_fetch.go new file mode 100644 index 0000000000000000000000000000000000000000..27aabd298be2a615daea94dc15d4cba22233d592 --- /dev/null +++ b/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), ¶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 + }) +} diff --git a/internal/agent/tools/web_fetch.md b/internal/agent/tools/web_fetch.md new file mode 100644 index 0000000000000000000000000000000000000000..d39d01578f1eaef4f76bc4198b47522f05982a22 --- /dev/null +++ b/internal/agent/tools/web_fetch.md @@ -0,0 +1,28 @@ +Fetches content from a web URL (for use by sub-agents). + + +- 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 + + + +- 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 + + + +- Max response size: 5MB +- Only supports HTTP and HTTPS protocols +- Cannot handle authentication or cookies +- Some websites may block automated requests + + + +- 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 + diff --git a/internal/config/config.go b/internal/config/config.go index 99c70079223127c71c3768da40d4aec0b55d62c7..6e7400f7746ebee4b5b65321ebe6fd50bedae26a 100644 --- a/internal/config/config.go +++ b/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 } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index d6c783bb63f5ac3000df0f212d6574a8fecc9ec2..efe2fb1612b3300c166196532149f89c79dc5716 100644 --- a/internal/config/load_test.go +++ b/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) { diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index e7151571ba14c0a0bdfd5923aea49704a07817ec..e67b713100e01e70040911d01e7f4b98c50ae805 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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 } diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index ce53ad3e763fc297d4ed72ad7164b52a64f7972e..aed0b21be11ca869d07ae960d1628264f0ddc644 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/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, ¶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) diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 808e02f4709574572c02902f710149919fa24fab..aa50f604d2ecf73bd1d27f704dc86b9655577608 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -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```") diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go index deda517add19a306d41320fdaab0c8895f63919e..549ac87ff7c1c59c4a26fa4a8afd3a2dea35802b 100644 --- a/internal/tui/styles/markdown.go +++ b/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, + }, + } +}