فهرست منبع

Merge branch 'main' into ui

Ayman Bagabas 3 ماه پیش
والد
کامیت
4cd294683e
100فایلهای تغییر یافته به همراه2291 افزوده شده و 1290 حذف شده
  1. 16 0
      .github/cla-signatures.json
  2. 7 0
      CRUSH.md
  3. 3 3
      README.md
  4. 15 11
      go.mod
  5. 30 23
      go.sum
  6. 41 25
      internal/agent/agent.go
  7. 0 4
      internal/agent/agent_tool.go
  8. 217 0
      internal/agent/agentic_fetch_tool.go
  9. 20 17
      internal/agent/coordinator.go
  10. 51 0
      internal/agent/templates/agentic_fetch.md
  11. 45 0
      internal/agent/templates/agentic_fetch_prompt.md.tpl
  12. 12 15
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml
  13. 12 12
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/download_tool.yaml
  14. 12 15
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/fetch_tool.yaml
  15. 14 11
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/glob_tool.yaml
  16. 13 10
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/grep_tool.yaml
  17. 13 13
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/ls_tool.yaml
  18. 11 11
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/multiedit_tool.yaml
  19. 16 13
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/parallel_tool_calls.yaml
  20. 11 11
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/read_a_file.yaml
  21. 15 12
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/simple_test.yaml
  22. 14 11
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/sourcegraph_tool.yaml
  23. 11 11
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/update_a_file.yaml
  24. 12 15
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/write_tool.yaml
  25. 11 13
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/bash_tool.yaml
  26. 11 13
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/download_tool.yaml
  27. 12 18
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/fetch_tool.yaml
  28. 14 14
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/glob_tool.yaml
  29. 15 17
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/grep_tool.yaml
  30. 18 12
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/ls_tool.yaml
  31. 21 13
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/multiedit_tool.yaml
  32. 14 16
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/parallel_tool_calls.yaml
  33. 8 10
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/read_a_file.yaml
  34. 6 8
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/simple_test.yaml
  35. 19 15
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/sourcegraph_tool.yaml
  36. 18 12
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/update_a_file.yaml
  37. 11 11
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/write_tool.yaml
  38. 12 10
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/bash_tool.yaml
  39. 12 18
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/download_tool.yaml
  40. 1 2
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/fetch_tool.yaml
  41. 11 9
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/glob_tool.yaml
  42. 8 18
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/grep_tool.yaml
  43. 8 16
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/ls_tool.yaml
  44. 8 12
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/multiedit_tool.yaml
  45. 10 12
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/parallel_tool_calls.yaml
  46. 1 1
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/read_a_file.yaml
  47. 6 8
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/simple_test.yaml
  48. 15 15
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/sourcegraph_tool.yaml
  49. 9 9
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/update_a_file.yaml
  50. 8 10
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/write_tool.yaml
  51. 10 10
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/bash_tool.yaml
  52. 13 9
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/download_tool.yaml
  53. 1 2
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/fetch_tool.yaml
  54. 9 11
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/glob_tool.yaml
  55. 14 8
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/grep_tool.yaml
  56. 1 2
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/ls_tool.yaml
  57. 10 14
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/multiedit_tool.yaml
  58. 1 1
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/parallel_tool_calls.yaml
  59. 9 7
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/read_a_file.yaml
  60. 5 7
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/simple_test.yaml
  61. 1 2
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/sourcegraph_tool.yaml
  62. 10 12
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/update_a_file.yaml
  63. 9 11
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/write_tool.yaml
  64. 0 18
      internal/agent/tools/fetch.go
  65. 18 1
      internal/agent/tools/fetch.md
  66. 96 0
      internal/agent/tools/fetch_helpers.go
  67. 41 0
      internal/agent/tools/fetch_types.go
  68. 29 442
      internal/agent/tools/mcp-tools.go
  69. 405 0
      internal/agent/tools/mcp/init.go
  70. 87 0
      internal/agent/tools/mcp/prompts.go
  71. 93 0
      internal/agent/tools/mcp/tools.go
  72. 6 1
      internal/agent/tools/view.go
  73. 72 0
      internal/agent/tools/web_fetch.go
  74. 28 0
      internal/agent/tools/web_fetch.md
  75. 61 18
      internal/app/app.go
  76. 2 2
      internal/cmd/dirs.go
  77. 14 5
      internal/cmd/root.go
  78. 14 5
      internal/cmd/run.go
  79. 1 1
      internal/cmd/update_providers.go
  80. 1 0
      internal/config/config.go
  81. 0 11
      internal/config/load.go
  82. 2 2
      internal/config/load_test.go
  83. 4 14
      internal/config/provider.go
  84. 1 1
      internal/db/connect.go
  85. 6 19
      internal/format/spinner.go
  86. 15 20
      internal/home/home.go
  87. 1 1
      internal/permission/permission.go
  88. 15 0
      internal/term/term.go
  89. 2 2
      internal/tui/components/anim/anim.go
  90. 5 4
      internal/tui/components/chat/chat.go
  91. 12 7
      internal/tui/components/chat/editor/editor.go
  92. 1 1
      internal/tui/components/chat/editor/keys.go
  93. 2 2
      internal/tui/components/chat/header/header.go
  94. 26 15
      internal/tui/components/chat/messages/messages.go
  95. 166 26
      internal/tui/components/chat/messages/renderer.go
  96. 66 13
      internal/tui/components/chat/messages/tool.go
  97. 1 1
      internal/tui/components/chat/queue.go
  98. 2 2
      internal/tui/components/chat/sidebar/sidebar.go
  99. 1 1
      internal/tui/components/chat/splash/keys.go
  100. 4 4
      internal/tui/components/chat/splash/splash.go

+ 16 - 0
.github/cla-signatures.json

@@ -807,6 +807,22 @@
       "created_at": "2025-11-01T10:06:05Z",
       "repoId": 987670088,
       "pullRequestNo": 1358
+    },
+    {
+      "name": "LarsArtmann",
+      "id": 23587853,
+      "comment_id": 3488527230,
+      "created_at": "2025-11-05T00:18:02Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1384
+    },
+    {
+      "name": "danielmerja",
+      "id": 30878766,
+      "comment_id": 3492618827,
+      "created_at": "2025-11-05T17:59:51Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1387
     }
   ]
 }

+ 7 - 0
CRUSH.md

@@ -60,6 +60,13 @@ func TestYourFunction(t *testing.T) {
   - You can also use `task fmt` to run `gofumpt -w .` on the entire project,
     as long as `gofumpt` is on the `PATH`.
 
+## Comments
+
+- Comments that live on their own lines should start with capital letters and
+  end with periods. Wrap comments at 78 columns.
+
 ## Committing
 
 - ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc).
+- Try to keep commits to one line, not including your attribution. Only use
+  multi-line commits when additional context is truly necessary.

+ 3 - 3
README.md

@@ -211,7 +211,7 @@ or globally, with the following priority:
 
 1. `.crush.json`
 2. `crush.json`
-3. `$HOME/.config/crush/crush.json` (Windows: `%USERPROFILE%\AppData\Local\crush\crush.json`)
+3. `$HOME/.config/crush/crush.json`
 
 Configuration itself is stored as a JSON object:
 
@@ -281,11 +281,11 @@ using `$(echo $VAR)` syntax.
     },
     "github": {
       "type": "http",
-      "url": "https://example.com/mcp/",
+      "url": "https://api.githubcopilot.com/mcp/",
       "timeout": 120,
       "disabled": false,
       "headers": {
-        "Authorization": "$(echo Bearer $EXAMPLE_MCP_TOKEN)"
+        "Authorization": "Bearer $GH_PAT"
       }
     },
     "streaming-service": {

+ 15 - 11
go.mod

@@ -3,7 +3,10 @@ module github.com/charmbracelet/crush
 go 1.25.0
 
 require (
-	charm.land/fantasy v0.1.5
+	charm.land/bubbles/v2 v2.0.0-beta.1.0.20251104200223-da0b892d1759
+	charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251105182244-3138f1cd1bf8
+	charm.land/fantasy v0.1.6
+	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251104200114-3aae28661422
 	github.com/JohannesKaufmann/html-to-markdown v1.6.0
 	github.com/MakeNowJust/heredoc v1.0.0
 	github.com/PuerkitoBio/goquery v1.10.3
@@ -12,18 +15,15 @@ require (
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.9.1
 	github.com/charlievieth/fastwalk v1.0.14
-	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2
-	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.6
 	github.com/charmbracelet/catwalk v0.8.2
-	github.com/charmbracelet/colorprofile v0.3.2
+	github.com/charmbracelet/colorprofile v0.3.3
 	github.com/charmbracelet/fang v0.4.3
 	github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018
-	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea
 	github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706
-	github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731
-	github.com/charmbracelet/x/ansi v0.10.2
+	github.com/charmbracelet/ultraviolet v0.0.0-20251105181648-75d1e37ff1bb
+	github.com/charmbracelet/x/ansi v0.10.3
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
-	github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
+	github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
 	github.com/charmbracelet/x/exp/ordered v0.1.0
 	github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5
 	github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
@@ -69,7 +69,7 @@ require (
 	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/andybalholm/cascadia v1.3.3 // indirect
-	github.com/aws/aws-sdk-go-v2 v1.39.4 // indirect
+	github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
 	github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
 	github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
@@ -88,11 +88,14 @@ require (
 	github.com/bahlo/generic-list-go v0.2.0 // indirect
 	github.com/buger/jsonparser v1.1.1 // indirect
 	github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
-	github.com/charmbracelet/go-genai v0.0.0-20251021165952-9befde14ce97 // indirect
+	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20251103214348-d3032608aa74 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
 	github.com/charmbracelet/x/json v0.2.0 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
+	github.com/clipperhouse/displaywidth v0.4.1 // indirect
+	github.com/clipperhouse/stringish v0.1.1 // indirect
+	github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/disintegration/gift v1.1.2 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
@@ -117,7 +120,7 @@ require (
 	github.com/klauspost/pgzip v1.2.6 // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
-	github.com/mattn/go-runewidth v0.0.17 // indirect
+	github.com/mattn/go-runewidth v0.0.19 // indirect
 	github.com/mfridman/interpolate v0.0.2 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -160,6 +163,7 @@ require (
 	golang.org/x/term v0.35.0 // indirect
 	golang.org/x/time v0.12.0 // indirect
 	google.golang.org/api v0.239.0 // indirect
+	google.golang.org/genai v1.33.1-0.20251103191629-d15baab4f79e // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
 	google.golang.org/grpc v1.74.2 // indirect
 	google.golang.org/protobuf v1.36.10 // indirect

+ 30 - 23
go.sum

@@ -1,5 +1,11 @@
-charm.land/fantasy v0.1.5 h1:7sta5yC+cSU32Kb+cNQb4b/3fyn13tYOgXsnXhdMlX0=
-charm.land/fantasy v0.1.5/go.mod h1:GT1Y8uYNmmu7OkUxWEiOyzdAf1jYopPJfpWvoDRzGiM=
+charm.land/bubbles/v2 v2.0.0-beta.1.0.20251104200223-da0b892d1759 h1:P1MxkVl8ZeI9tHmmrn9UzV/5Mz7heoiTgqECHRFsUcs=
+charm.land/bubbles/v2 v2.0.0-beta.1.0.20251104200223-da0b892d1759/go.mod h1:G7JWaj3kDT0BDB+h5BLDUhhBLpDoRLKrpOp5QrA2SQs=
+charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251105182244-3138f1cd1bf8 h1:A1y0nyy7ykH1judtnD36sgpepLBrU4y7mN6QlZEVhZk=
+charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251105182244-3138f1cd1bf8/go.mod h1:oR2A+f83vzDY0hALwW4eh90fKXdranRWnH/vfwJL1lU=
+charm.land/fantasy v0.1.6 h1:laomMUqUaniQoLx7UOb+MLUpIGJPoNwsXvw1PbzgnB8=
+charm.land/fantasy v0.1.6/go.mod h1:JpFcJ5zs/1CjmYYGAZ7GaFmeBv0mPaTzEPRG6Eic5pc=
+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251104200114-3aae28661422 h1:LcW3SSv1EZvlb9pfaVZIZyHrPVRJdb0adgX+tWPYl0k=
+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251104200114-3aae28661422/go.mod h1:0EJAlA1PDGb+2RyyC02yDSPDwvpegDefu74HC9Blg5o=
 cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
 cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
 cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
@@ -34,8 +40,8 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
 github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/aws/aws-sdk-go-v2 v1.39.4 h1:qTsQKcdQPHnfGYBBs+Btl8QwxJeoWcOcPcixK90mRhg=
-github.com/aws/aws-sdk-go-v2 v1.39.4/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM=
+github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w=
+github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
 github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
@@ -78,34 +84,28 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
 github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.6 h1:nXNg4TmtfoQXFdF2BSSjTxFp9bSHQCILkIKK3FXMW/E=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.6/go.mod h1:SUTLq+/pGQ5qntHgt0JswfVJFfgJgWDqyvyiSLVlmbo=
 github.com/charmbracelet/catwalk v0.8.2 h1:J7xq/ft/ZByJCHl3JpgvxlCd59bzZPugy66XuoL4vAs=
 github.com/charmbracelet/catwalk v0.8.2/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
-github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
-github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
+github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
+github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
 github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY=
 github.com/charmbracelet/fang v0.4.3/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg=
 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 h1:PU4Zvpagsk5sgaDxn5W4sxHuLp9QRMBZB3bFSk40A4w=
 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018/go.mod h1:Z/GLmp9fzaqX4ze3nXG7StgWez5uBM5XtlLHK8V/qSk=
-github.com/charmbracelet/go-genai v0.0.0-20251021165952-9befde14ce97 h1:HK7B5Q+0FidxjQD5CovniMw7axkUeMHwgVkxkbmiW/s=
-github.com/charmbracelet/go-genai v0.0.0-20251021165952-9befde14ce97/go.mod h1:ZagL2esO4qxlOJBj0d4PVvLM82akQFtne8s3ivxBnTQ=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea/go.mod h1:ngHerf1JLJXBrDXdphn5gFrBPriCL437uwukd5c93pM=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20251103214348-d3032608aa74 h1:2N+CxpUFM6Rrx+xT7XaqM9pp/psOFlxKWa5R7rP/lck=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20251103214348-d3032608aa74/go.mod h1:RfXmCdNs2F4MVJjBVQp5RZYXR05MiRAHN4GHwWmsNIA=
 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
-github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731 h1:Lr+igmzKpLPdb8yUZBP9noYWwCZP042z2nWPrJZTc+8=
-github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731/go.mod h1:KfWwUa0Oe//D72YlhbOq/g40L7UiGtATrvsGI3cciG8=
-github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
-github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
+github.com/charmbracelet/ultraviolet v0.0.0-20251105181648-75d1e37ff1bb h1:KZnKSrGjarKScpekDuPAVnlMSMtA7mdzmoUD0AhAZC0=
+github.com/charmbracelet/ultraviolet v0.0.0-20251105181648-75d1e37ff1bb/go.mod h1:G7cNuWgmuugx6ApJv4kDGfnFanoDAz8AWazH9lSoWdw=
+github.com/charmbracelet/x/ansi v0.10.3 h1:3WoV9XN8uMEnFRZZ+vBPRy59TaIWa+gJodS4Vg5Fut0=
+github.com/charmbracelet/x/ansi v0.10.3/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE=
 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a h1:zYSNtEJM9jwHbJts2k+Hroj+xQwsW1yxc4Wopdv7KaI=
 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a/go.mod h1:rc2bsPC6MWae3LdOxNO1mOb443NlMrrDL0xEya48NNc=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
-github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
-github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
 github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
 github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI=
@@ -120,6 +120,12 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
 github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
 github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.4.1 h1:uVw9V8UDfnggg3K2U84VWY1YLQ/x2aKSCtkRyYozfoU=
+github.com/clipperhouse/displaywidth v0.4.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
+github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
 github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
@@ -206,8 +212,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
-github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
 github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
 github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
@@ -255,7 +261,6 @@ github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsf
 github.com/qjebbs/go-jsons v1.0.0-alpha.4/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -437,6 +442,8 @@ golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
 google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
+google.golang.org/genai v1.33.1-0.20251103191629-d15baab4f79e h1:pGBT6ptC4ENtN9wA4dGhvjwrYpVZ6X9Lnpwu4Y+jozk=
+google.golang.org/genai v1.33.1-0.20251103191629-d15baab4f79e/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
 google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=

+ 41 - 25
internal/agent/agent.go

@@ -1,3 +1,10 @@
+// Package agent is the core orchestration layer for Crush AI agents.
+//
+// It provides session-based AI agent functionality for managing
+// conversations, tool execution, and message handling. It coordinates
+// interactions between language models, messages, sessions, and tools while
+// handling features like automatic summarization, queuing, and token
+// management.
 package agent
 
 import (
@@ -131,7 +138,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 	}
 
 	if len(a.tools) > 0 {
-		// add anthropic caching to the last tool
+		// Add Anthropic caching to the last tool.
 		a.tools[len(a.tools)-1].SetProviderOptions(a.getCacheControlOptions())
 	}
 
@@ -153,7 +160,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 	}
 
 	var wg sync.WaitGroup
-	// Generate title if first message
+	// Generate title if first message.
 	if len(msgs) == 0 {
 		wg.Go(func() {
 			sessionLock.Lock()
@@ -162,13 +169,13 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 		})
 	}
 
-	// Add the user message to the session
+	// Add the user message to the session.
 	_, err = a.createUserMessage(ctx, call)
 	if err != nil {
 		return nil, err
 	}
 
-	// add the session to the context
+	// Add the session to the context.
 	ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
 
 	genCtx, cancel := context.WithCancel(ctx)
@@ -195,10 +202,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 		PresencePenalty:  call.PresencePenalty,
 		TopK:             call.TopK,
 		FrequencyPenalty: call.FrequencyPenalty,
-		// Before each step create the new assistant message
+		// Before each step create a new assistant message.
 		PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
 			prepared.Messages = options.Messages
-			// reset all cached items
+			// Reset all cached items.
 			for i := range prepared.Messages {
 				prepared.Messages[i].ProviderOptions = nil
 			}
@@ -216,14 +223,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			lastSystemRoleInx := 0
 			systemMessageUpdated := false
 			for i, msg := range prepared.Messages {
-				// only add cache control to the last message
+				// Only add cache control to the last message.
 				if msg.Role == fantasy.MessageRoleSystem {
 					lastSystemRoleInx = i
 				} else if !systemMessageUpdated {
 					prepared.Messages[lastSystemRoleInx].ProviderOptions = a.getCacheControlOptions()
 					systemMessageUpdated = true
 				}
-				// than add cache control to the last 2 messages
+				// Than add cache control to the last 2 messages.
 				if i > len(prepared.Messages)-3 {
 					prepared.Messages[i].ProviderOptions = a.getCacheControlOptions()
 				}
@@ -276,6 +283,13 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			return a.messages.Update(genCtx, *currentAssistant)
 		},
 		OnTextDelta: func(id string, text string) error {
+			// Strip leading newline from initial text content. This is is
+			// particularly important in non-interactive mode where leading
+			// newlines are very visible.
+			if len(currentAssistant.Parts) == 0 {
+				text = strings.TrimPrefix(text, "\n")
+			}
+
 			currentAssistant.AppendContent(text)
 			return a.messages.Update(genCtx, *currentAssistant)
 		},
@@ -387,10 +401,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 		if currentAssistant == nil {
 			return result, err
 		}
-		// Ensure we finish thinking on error to close the reasoning state
+		// Ensure we finish thinking on error to close the reasoning state.
 		currentAssistant.FinishThinking()
 		toolCalls := currentAssistant.ToolCalls()
-		// INFO: we use the parent context here because the genCtx has been cancelled
+		// INFO: we use the parent context here because the genCtx has been cancelled.
 		msgs, createErr := a.messages.List(ctx, currentAssistant.SessionID)
 		if createErr != nil {
 			return nil, createErr
@@ -427,7 +441,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			if isCancelErr {
 				content = "Tool execution canceled by user"
 			} else if isPermissionErr {
-				content = "Permission denied"
+				content = "User denied permission"
 			}
 			toolResult := message.ToolResult{
 				ToolCallID: tc.ID,
@@ -446,13 +460,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			}
 		}
 		if isCancelErr {
-			currentAssistant.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
+			currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
 		} else if isPermissionErr {
-			currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "Permission denied", "")
+			currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
 		} else {
 			currentAssistant.AddFinish(message.FinishReasonError, "API Error", err.Error())
 		}
-		// INFO: we use the parent context here because the genCtx has been cancelled
+		// Note: we use the parent context here because the genCtx has been
+		// cancelled.
 		updateErr := a.messages.Update(ctx, *currentAssistant)
 		if updateErr != nil {
 			return nil, updateErr
@@ -466,7 +481,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 		if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
 			return nil, summarizeErr
 		}
-		// if the agent was not done...
+		// If the agent wasn't done...
 		if len(currentAssistant.ToolCalls()) > 0 {
 			existing, ok := a.messageQueue.Get(call.SessionID)
 			if !ok {
@@ -478,7 +493,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 		}
 	}
 
-	// release active request before processing queued messages
+	// Release active request before processing queued messages.
 	a.activeRequests.Del(call.SessionID)
 	cancel()
 
@@ -486,7 +501,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 	if !ok || len(queuedMessages) == 0 {
 		return result, err
 	}
-	// there are queued messages restart the loop
+	// There are queued messages restart the loop.
 	firstQueuedMessage := queuedMessages[0]
 	a.messageQueue.Set(call.SessionID, queuedMessages[1:])
 	return a.Run(ctx, firstQueuedMessage)
@@ -506,7 +521,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
 		return err
 	}
 	if len(msgs) == 0 {
-		// nothing to summarize
+		// Nothing to summarize.
 		return nil
 	}
 
@@ -546,7 +561,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
 			return a.messages.Update(genCtx, summaryMessage)
 		},
 		OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
-			// handle anthropic signature
+			// Handle anthropic signature.
 			if anthropicData, ok := reasoning.ProviderMetadata["anthropic"]; ok {
 				if signature, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok && signature.Signature != "" {
 					summaryMessage.AppendReasoningSignature(signature.Signature)
@@ -563,7 +578,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
 	if err != nil {
 		isCancelErr := errors.Is(err, context.Canceled)
 		if isCancelErr {
-			// User cancelled summarize we need to remove the summary message
+			// User cancelled summarize we need to remove the summary message.
 			deleteErr := a.messages.Delete(ctx, summaryMessage.ID)
 			return deleteErr
 		}
@@ -590,7 +605,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
 
 	a.updateSessionUsage(a.largeModel, &currentSession, resp.TotalUsage, openrouterCost)
 
-	// just in case get just the last usage
+	// Just in case, get just the last usage info.
 	usage := resp.Response.Usage
 	currentSession.SummaryMessageID = summaryMessage.ID
 	currentSession.CompletionTokens = usage.OutputTokens
@@ -636,7 +651,8 @@ func (a *sessionAgent) preparePrompt(msgs []message.Message, attachments ...mess
 		if len(m.Parts) == 0 {
 			continue
 		}
-		// Assistant message without content or tool calls (cancelled before it returned anything)
+		// Assistant message without content or tool calls (cancelled before it
+		// returned anything).
 		if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
 			continue
 		}
@@ -711,7 +727,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Sessi
 
 	title = strings.ReplaceAll(title, "\n", " ")
 
-	// remove thinking tags if present
+	// Remove thinking tags if present.
 	if idx := strings.Index(title, "</think>"); idx > 0 {
 		title = title[idx+len("</think>"):]
 	}
@@ -777,13 +793,13 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session,
 }
 
 func (a *sessionAgent) Cancel(sessionID string) {
-	// Cancel regular requests
+	// Cancel regular requests.
 	if cancel, ok := a.activeRequests.Take(sessionID); ok && cancel != nil {
 		slog.Info("Request cancellation initiated", "session_id", sessionID)
 		cancel()
 	}
 
-	// Also check for summarize requests
+	// Also check for summarize requests.
 	if cancel, ok := a.activeRequests.Take(sessionID + "-summarize"); ok && cancel != nil {
 		slog.Info("Summarize cancellation initiated", "session_id", sessionID)
 		cancel()

+ 0 - 4
internal/agent/agent_tool.go

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

+ 217 - 0
internal/agent/agentic_fetch_tool.go

@@ -0,0 +1,217 @@
+package agent
+
+import (
+	"context"
+	_ "embed"
+	"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/permission"
+)
+
+//go:embed templates/agentic_fetch.md
+var agenticFetchToolDescription []byte
+
+// agenticFetchValidationResult holds the validated parameters from the tool call context.
+type agenticFetchValidationResult struct {
+	SessionID      string
+	AgentMessageID string
+}
+
+// validateAgenticFetchParams validates the tool call parameters and extracts required context values.
+func validateAgenticFetchParams(ctx context.Context, params tools.AgenticFetchParams) (agenticFetchValidationResult, error) {
+	if params.URL == "" {
+		return agenticFetchValidationResult{}, errors.New("url is required")
+	}
+
+	if params.Prompt == "" {
+		return agenticFetchValidationResult{}, errors.New("prompt is required")
+	}
+
+	sessionID := tools.GetSessionFromContext(ctx)
+	if sessionID == "" {
+		return agenticFetchValidationResult{}, errors.New("session id missing from context")
+	}
+
+	agentMessageID := tools.GetMessageFromContext(ctx)
+	if agentMessageID == "" {
+		return agenticFetchValidationResult{}, errors.New("agent message id missing from context")
+	}
+
+	return agenticFetchValidationResult{
+		SessionID:      sessionID,
+		AgentMessageID: agentMessageID,
+	}, nil
+}
+
+//go:embed templates/agentic_fetch_prompt.md.tpl
+var agenticFetchPromptTmpl []byte
+
+func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (fantasy.AgentTool, error) {
+	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.AgenticFetchToolName,
+		string(agenticFetchToolDescription),
+		func(ctx context.Context, params tools.AgenticFetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			validationResult, err := validateAgenticFetchParams(ctx, params)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(err.Error()), nil
+			}
+
+			p := c.permissions.Request(
+				permission.CreatePermissionRequest{
+					SessionID:   validationResult.SessionID,
+					Path:        c.cfg.WorkingDir(),
+					ToolCallID:  call.ID,
+					ToolName:    tools.AgenticFetchToolName,
+					Action:      "fetch",
+					Description: fmt.Sprintf("Fetch and analyze content from URL: %s", params.URL),
+					Params:      tools.AgenticFetchPermissionsParams(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(c.cfg.Options.DataDirectory, "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("agentic_fetch", string(agenticFetchPromptTmpl), 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(validationResult.AgentMessageID, call.ID)
+			session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, validationResult.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, validationResult.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
+}

+ 20 - 17
internal/agent/coordinator.go

@@ -319,6 +319,14 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		allTools = append(allTools, agentTool)
 	}
 
+	if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
+		agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
+		if err != nil {
+			return nil, err
+		}
+		allTools = append(allTools, agenticFetchTool)
+	}
+
 	allTools = append(allTools,
 		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution),
 		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
@@ -344,28 +352,24 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		}
 	}
 
-	mcpTools := tools.GetMCPTools(context.Background(), c.permissions, c.cfg)
-
-	for _, mcpTool := range mcpTools {
+	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
 		if agent.AllowedMCP == nil {
 			// No MCP restrictions
-			filteredTools = append(filteredTools, mcpTool)
-		} else if len(agent.AllowedMCP) == 0 {
-			// no mcps allowed
+			filteredTools = append(filteredTools, tool)
+			continue
+		}
+		if len(agent.AllowedMCP) == 0 {
+			// No MCPs allowed
+			slog.Warn("MCPs not allowed")
 			break
 		}
 
 		for mcp, tools := range agent.AllowedMCP {
-			if mcp == mcpTool.MCP() {
-				if len(tools) == 0 {
-					filteredTools = append(filteredTools, mcpTool)
-				}
-				for _, t := range tools {
-					if t == mcpTool.MCPToolName() {
-						filteredTools = append(filteredTools, mcpTool)
-					}
-				}
-				break
+			if mcp != tool.MCP() {
+				continue
+			}
+			if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
+				filteredTools = append(filteredTools, tool)
 			}
 		}
 	}
@@ -658,7 +662,6 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
 		}
 	}
 
-	// TODO: make sure we have
 	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
 	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
 

+ 51 - 0
internal/agent/templates/agentic_fetch.md

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

+ 45 - 0
internal/agent/templates/agentic_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>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 12 - 15
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 12 - 12
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/download_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 12 - 15
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/fetch_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 14 - 11
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/glob_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 13 - 10
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/grep_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 13 - 13
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/ls_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 11 - 11
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/multiedit_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 16 - 13
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/parallel_tool_calls.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 11 - 11
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/read_a_file.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 15 - 12
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/simple_test.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 14 - 11
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/sourcegraph_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 11 - 11
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/update_a_file.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 12 - 15
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/write_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 11 - 13
internal/agent/testdata/TestCoderAgent/openai-gpt-5/bash_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 11 - 13
internal/agent/testdata/TestCoderAgent/openai-gpt-5/download_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 12 - 18
internal/agent/testdata/TestCoderAgent/openai-gpt-5/fetch_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 14 - 14
internal/agent/testdata/TestCoderAgent/openai-gpt-5/glob_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 15 - 17
internal/agent/testdata/TestCoderAgent/openai-gpt-5/grep_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 18 - 12
internal/agent/testdata/TestCoderAgent/openai-gpt-5/ls_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 21 - 13
internal/agent/testdata/TestCoderAgent/openai-gpt-5/multiedit_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 14 - 16
internal/agent/testdata/TestCoderAgent/openai-gpt-5/parallel_tool_calls.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 8 - 10
internal/agent/testdata/TestCoderAgent/openai-gpt-5/read_a_file.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 6 - 8
internal/agent/testdata/TestCoderAgent/openai-gpt-5/simple_test.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 19 - 15
internal/agent/testdata/TestCoderAgent/openai-gpt-5/sourcegraph_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 18 - 12
internal/agent/testdata/TestCoderAgent/openai-gpt-5/update_a_file.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 11 - 11
internal/agent/testdata/TestCoderAgent/openai-gpt-5/write_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 12 - 10
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/bash_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 12 - 18
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/download_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 2
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/fetch_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 11 - 9
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/glob_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 8 - 18
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/grep_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 8 - 16
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/ls_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 8 - 12
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/multiedit_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 10 - 12
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/parallel_tool_calls.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 1
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/read_a_file.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 6 - 8
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/simple_test.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 15 - 15
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/sourcegraph_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 9 - 9
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/update_a_file.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 8 - 10
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/write_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 10 - 10
internal/agent/testdata/TestCoderAgent/zai-glm4.6/bash_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 13 - 9
internal/agent/testdata/TestCoderAgent/zai-glm4.6/download_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 2
internal/agent/testdata/TestCoderAgent/zai-glm4.6/fetch_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 9 - 11
internal/agent/testdata/TestCoderAgent/zai-glm4.6/glob_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 14 - 8
internal/agent/testdata/TestCoderAgent/zai-glm4.6/grep_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 2
internal/agent/testdata/TestCoderAgent/zai-glm4.6/ls_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 10 - 14
internal/agent/testdata/TestCoderAgent/zai-glm4.6/multiedit_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 1
internal/agent/testdata/TestCoderAgent/zai-glm4.6/parallel_tool_calls.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 9 - 7
internal/agent/testdata/TestCoderAgent/zai-glm4.6/read_a_file.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 7
internal/agent/testdata/TestCoderAgent/zai-glm4.6/simple_test.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 2
internal/agent/testdata/TestCoderAgent/zai-glm4.6/sourcegraph_tool.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 10 - 12
internal/agent/testdata/TestCoderAgent/zai-glm4.6/update_a_file.yaml


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 9 - 11
internal/agent/testdata/TestCoderAgent/zai-glm4.6/write_tool.yaml


+ 0 - 18
internal/agent/tools/fetch.go

@@ -16,24 +16,6 @@ import (
 	"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

+ 18 - 1
internal/agent/tools/fetch.md

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

+ 96 - 0
internal/agent/tools/fetch_helpers.go

@@ -0,0 +1,96 @@
+package tools
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"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
+	} else if strings.Contains(contentType, "application/json") || strings.Contains(contentType, "text/json") {
+		// Format JSON for better readability.
+		formatted, err := FormatJSON(content)
+		if err == nil {
+			content = formatted
+		}
+		// If formatting fails, keep original content.
+	}
+
+	return content, nil
+}
+
+// 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
+}
+
+// FormatJSON formats JSON content with proper indentation.
+func FormatJSON(content string) (string, error) {
+	var data interface{}
+	if err := json.Unmarshal([]byte(content), &data); err != nil {
+		return "", err
+	}
+
+	var buf bytes.Buffer
+	encoder := json.NewEncoder(&buf)
+	encoder.SetIndent("", "  ")
+	if err := encoder.Encode(data); err != nil {
+		return "", err
+	}
+
+	return buf.String(), nil
+}

+ 41 - 0
internal/agent/tools/fetch_types.go

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

+ 29 - 442
internal/agent/tools/mcp-tools.go

@@ -1,94 +1,32 @@
 package tools
 
 import (
-	"cmp"
 	"context"
-	"encoding/json"
-	"errors"
 	"fmt"
-	"io"
-	"log/slog"
-	"maps"
-	"net/http"
-	"os"
-	"os/exec"
-	"slices"
-	"strings"
-	"sync"
-	"time"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/permission"
-	"github.com/charmbracelet/crush/internal/pubsub"
-	"github.com/charmbracelet/crush/internal/version"
-	"github.com/modelcontextprotocol/go-sdk/mcp"
 )
 
-// MCPState represents the current state of an MCP client
-type MCPState int
-
-const (
-	MCPStateDisabled MCPState = iota
-	MCPStateStarting
-	MCPStateConnected
-	MCPStateError
-)
-
-func (s MCPState) String() string {
-	switch s {
-	case MCPStateDisabled:
-		return "disabled"
-	case MCPStateStarting:
-		return "starting"
-	case MCPStateConnected:
-		return "connected"
-	case MCPStateError:
-		return "error"
-	default:
-		return "unknown"
+// GetMCPTools gets all the currently available MCP tools.
+func GetMCPTools(permissions permission.Service, wd string) []*Tool {
+	var result []*Tool
+	for mcpName, tools := range mcp.Tools() {
+		for _, tool := range tools {
+			result = append(result, &Tool{
+				mcpName:     mcpName,
+				tool:        tool,
+				permissions: permissions,
+				workingDir:  wd,
+			})
+		}
 	}
+	return result
 }
 
-// MCPEventType represents the type of MCP event
-type MCPEventType string
-
-const (
-	MCPEventStateChanged     MCPEventType = "state_changed"
-	MCPEventToolsListChanged MCPEventType = "tools_list_changed"
-)
-
-// MCPEvent represents an event in the MCP system
-type MCPEvent struct {
-	Type      MCPEventType
-	Name      string
-	State     MCPState
-	Error     error
-	ToolCount int
-}
-
-// MCPClientInfo holds information about an MCP client's state
-type MCPClientInfo struct {
-	Name        string
-	State       MCPState
-	Error       error
-	Client      *mcp.ClientSession
-	ToolCount   int
-	ConnectedAt time.Time
-}
-
-var (
-	mcpToolsOnce    sync.Once
-	mcpTools        = csync.NewMap[string, *McpTool]()
-	mcpClient2Tools = csync.NewMap[string, []*McpTool]()
-	mcpClients      = csync.NewMap[string, *mcp.ClientSession]()
-	mcpStates       = csync.NewMap[string, MCPClientInfo]()
-	mcpBroker       = pubsub.NewBroker[MCPEvent]()
-)
-
-type McpTool struct {
+// Tool is a tool from a MCP.
+type Tool struct {
 	mcpName         string
 	tool            *mcp.Tool
 	permissions     permission.Service
@@ -96,31 +34,31 @@ type McpTool struct {
 	providerOptions fantasy.ProviderOptions
 }
 
-func (m *McpTool) SetProviderOptions(opts fantasy.ProviderOptions) {
+func (m *Tool) SetProviderOptions(opts fantasy.ProviderOptions) {
 	m.providerOptions = opts
 }
 
-func (m *McpTool) ProviderOptions() fantasy.ProviderOptions {
+func (m *Tool) ProviderOptions() fantasy.ProviderOptions {
 	return m.providerOptions
 }
 
-func (m *McpTool) Name() string {
+func (m *Tool) Name() string {
 	return fmt.Sprintf("mcp_%s_%s", m.mcpName, m.tool.Name)
 }
 
-func (m *McpTool) MCP() string {
+func (m *Tool) MCP() string {
 	return m.mcpName
 }
 
-func (m *McpTool) MCPToolName() string {
+func (m *Tool) MCPToolName() string {
 	return m.tool.Name
 }
 
-func (b *McpTool) Info() fantasy.ToolInfo {
+func (m *Tool) Info() fantasy.ToolInfo {
 	parameters := make(map[string]any)
 	required := make([]string, 0)
 
-	if input, ok := b.tool.InputSchema.(map[string]any); ok {
+	if input, ok := m.tool.InputSchema.(map[string]any); ok {
 		if props, ok := input["properties"].(map[string]any); ok {
 			parameters = props
 		}
@@ -138,72 +76,14 @@ func (b *McpTool) Info() fantasy.ToolInfo {
 	}
 
 	return fantasy.ToolInfo{
-		Name:        fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
-		Description: b.tool.Description,
+		Name:        m.Name(),
+		Description: m.tool.Description,
 		Parameters:  parameters,
 		Required:    required,
 	}
 }
 
-func runTool(ctx context.Context, name, toolName string, input string) (fantasy.ToolResponse, error) {
-	var args map[string]any
-	if err := json.Unmarshal([]byte(input), &args); err != nil {
-		return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	c, err := getOrRenewClient(ctx, name)
-	if err != nil {
-		return fantasy.NewTextErrorResponse(err.Error()), nil
-	}
-	result, err := c.CallTool(ctx, &mcp.CallToolParams{
-		Name:      toolName,
-		Arguments: args,
-	})
-	if err != nil {
-		return fantasy.NewTextErrorResponse(err.Error()), nil
-	}
-
-	output := make([]string, 0, len(result.Content))
-	for _, v := range result.Content {
-		if vv, ok := v.(*mcp.TextContent); ok {
-			output = append(output, vv.Text)
-		} else {
-			output = append(output, fmt.Sprintf("%v", v))
-		}
-	}
-	return fantasy.NewTextResponse(strings.Join(output, "\n")), nil
-}
-
-func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) {
-	sess, ok := mcpClients.Get(name)
-	if !ok {
-		return nil, fmt.Errorf("mcp '%s' not available", name)
-	}
-
-	cfg := config.Get()
-	m := cfg.MCP[name]
-	state, _ := mcpStates.Get(name)
-
-	timeout := mcpTimeout(m)
-	pingCtx, cancel := context.WithTimeout(ctx, timeout)
-	defer cancel()
-	err := sess.Ping(pingCtx, nil)
-	if err == nil {
-		return sess, nil
-	}
-	updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, state.ToolCount)
-
-	sess, err = createMCPSession(ctx, name, m, cfg.Resolver())
-	if err != nil {
-		return nil, err
-	}
-
-	updateMCPState(name, MCPStateConnected, nil, sess, state.ToolCount)
-	mcpClients.Set(name, sess)
-	return sess, nil
-}
-
-func (m *McpTool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolResponse, error) {
+func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolResponse, error) {
 	sessionID := GetSessionFromContext(ctx)
 	if sessionID == "" {
 		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
@@ -224,302 +104,9 @@ func (m *McpTool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.Too
 		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
 	}
 
-	return runTool(ctx, m.mcpName, m.tool.Name, params.Input)
-}
-
-func getTools(ctx context.Context, name string, permissions permission.Service, c *mcp.ClientSession, workingDir string) ([]*McpTool, error) {
-	result, err := c.ListTools(ctx, &mcp.ListToolsParams{})
+	content, err := mcp.RunTool(ctx, m.mcpName, m.tool.Name, params.Input)
 	if err != nil {
-		return nil, err
-	}
-	mcpTools := make([]*McpTool, 0, len(result.Tools))
-	for _, tool := range result.Tools {
-		mcpTools = append(mcpTools, &McpTool{
-			mcpName:     name,
-			tool:        tool,
-			permissions: permissions,
-			workingDir:  workingDir,
-		})
-	}
-	return mcpTools, nil
-}
-
-// SubscribeMCPEvents returns a channel for MCP events
-func SubscribeMCPEvents(ctx context.Context) <-chan pubsub.Event[MCPEvent] {
-	return mcpBroker.Subscribe(ctx)
-}
-
-// GetMCPStates returns the current state of all MCP clients
-func GetMCPStates() map[string]MCPClientInfo {
-	return maps.Collect(mcpStates.Seq2())
-}
-
-// GetMCPState returns the state of a specific MCP client
-func GetMCPState(name string) (MCPClientInfo, bool) {
-	return mcpStates.Get(name)
-}
-
-// updateMCPState updates the state of an MCP client and publishes an event
-func updateMCPState(name string, state MCPState, err error, client *mcp.ClientSession, toolCount int) {
-	info := MCPClientInfo{
-		Name:      name,
-		State:     state,
-		Error:     err,
-		Client:    client,
-		ToolCount: toolCount,
-	}
-	switch state {
-	case MCPStateConnected:
-		info.ConnectedAt = time.Now()
-	case MCPStateError:
-		updateMcpTools(name, nil)
-		mcpClients.Del(name)
-	}
-	mcpStates.Set(name, info)
-
-	// Publish state change event
-	mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
-		Type:      MCPEventStateChanged,
-		Name:      name,
-		State:     state,
-		Error:     err,
-		ToolCount: toolCount,
-	})
-}
-
-// CloseMCPClients closes all MCP clients. This should be called during application shutdown.
-func CloseMCPClients() error {
-	var errs []error
-	for name, c := range mcpClients.Seq2() {
-		if err := c.Close(); err != nil &&
-			!errors.Is(err, io.EOF) &&
-			!errors.Is(err, context.Canceled) &&
-			err.Error() != "signal: killed" {
-			errs = append(errs, fmt.Errorf("close mcp: %s: %w", name, err))
-		}
-	}
-	mcpBroker.Shutdown()
-	return errors.Join(errs...)
-}
-
-func GetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []*McpTool {
-	mcpToolsOnce.Do(func() {
-		var wg sync.WaitGroup
-		// Initialize states for all configured MCPs
-		for name, m := range cfg.MCP {
-			if m.Disabled {
-				updateMCPState(name, MCPStateDisabled, nil, nil, 0)
-				slog.Debug("skipping disabled mcp", "name", name)
-				continue
-			}
-
-			// Set initial starting state
-			updateMCPState(name, MCPStateStarting, nil, nil, 0)
-
-			wg.Add(1)
-			go func(name string, m config.MCPConfig) {
-				defer func() {
-					wg.Done()
-					if r := recover(); r != nil {
-						var err error
-						switch v := r.(type) {
-						case error:
-							err = v
-						case string:
-							err = fmt.Errorf("panic: %s", v)
-						default:
-							err = fmt.Errorf("panic: %v", v)
-						}
-						updateMCPState(name, MCPStateError, err, nil, 0)
-						slog.Error("panic in mcp client initialization", "error", err, "name", name)
-					}
-				}()
-
-				ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
-				defer cancel()
-
-				c, err := createMCPSession(ctx, name, m, cfg.Resolver())
-				if err != nil {
-					return
-				}
-
-				mcpClients.Set(name, c)
-
-				tools, err := getTools(ctx, name, permissions, c, cfg.WorkingDir())
-				if err != nil {
-					slog.Error("error listing tools", "error", err)
-					updateMCPState(name, MCPStateError, err, nil, 0)
-					c.Close()
-					return
-				}
-
-				updateMcpTools(name, tools)
-				mcpClients.Set(name, c)
-				updateMCPState(name, MCPStateConnected, nil, c, len(tools))
-			}(name, m)
-		}
-		wg.Wait()
-	})
-	return slices.Collect(mcpTools.Seq())
-}
-
-// updateMcpTools updates the global mcpTools and mcpClient2Tools maps
-func updateMcpTools(mcpName string, tools []*McpTool) {
-	if len(tools) == 0 {
-		mcpClient2Tools.Del(mcpName)
-	} else {
-		mcpClient2Tools.Set(mcpName, tools)
-	}
-	for _, tools := range mcpClient2Tools.Seq2() {
-		for _, t := range tools {
-			mcpTools.Set(t.Name(), t)
-		}
-	}
-}
-
-func createMCPSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*mcp.ClientSession, error) {
-	timeout := mcpTimeout(m)
-	mcpCtx, cancel := context.WithCancel(ctx)
-	cancelTimer := time.AfterFunc(timeout, cancel)
-
-	transport, err := createMCPTransport(mcpCtx, m, resolver)
-	if err != nil {
-		updateMCPState(name, MCPStateError, err, nil, 0)
-		slog.Error("error creating mcp client", "error", err, "name", name)
-		cancel()
-		cancelTimer.Stop()
-		return nil, err
-	}
-
-	client := mcp.NewClient(
-		&mcp.Implementation{
-			Name:    "crush",
-			Version: version.Version,
-			Title:   "Crush",
-		},
-		&mcp.ClientOptions{
-			ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {
-				mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
-					Type: MCPEventToolsListChanged,
-					Name: name,
-				})
-			},
-			KeepAlive: time.Minute * 10,
-		},
-	)
-
-	session, err := client.Connect(mcpCtx, transport, nil)
-	if err != nil {
-		err = maybeStdioErr(err, transport)
-		updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
-		slog.Error("error starting mcp client", "error", err, "name", name)
-		cancel()
-		cancelTimer.Stop()
-		return nil, err
-	}
-
-	cancelTimer.Stop()
-	slog.Info("Initialized mcp client", "name", name)
-	return session, nil
-}
-
-// maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail
-// to parse, and the cli will then close it, causing the EOF error.
-// so, if we got an EOF err, and the transport is STDIO, we try to exec it
-// again with a timeout and collect the output so we can add details to the
-// error.
-// this happens particularly when starting things with npx, e.g. if node can't
-// be found or some other error like that.
-func maybeStdioErr(err error, transport mcp.Transport) error {
-	if !errors.Is(err, io.EOF) {
-		return err
-	}
-	ct, ok := transport.(*mcp.CommandTransport)
-	if !ok {
-		return err
-	}
-	if err2 := stdioMCPCheck(ct.Command); err2 != nil {
-		err = errors.Join(err, err2)
-	}
-	return err
-}
-
-func maybeTimeoutErr(err error, timeout time.Duration) error {
-	if errors.Is(err, context.Canceled) {
-		return fmt.Errorf("timed out after %s", timeout)
-	}
-	return err
-}
-
-func createMCPTransport(ctx context.Context, m config.MCPConfig, resolver config.VariableResolver) (mcp.Transport, error) {
-	switch m.Type {
-	case config.MCPStdio:
-		command, err := resolver.ResolveValue(m.Command)
-		if err != nil {
-			return nil, fmt.Errorf("invalid mcp command: %w", err)
-		}
-		if strings.TrimSpace(command) == "" {
-			return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
-		}
-		cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
-		cmd.Env = append(os.Environ(), m.ResolvedEnv()...)
-		return &mcp.CommandTransport{
-			Command: cmd,
-		}, nil
-	case config.MCPHttp:
-		if strings.TrimSpace(m.URL) == "" {
-			return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
-		}
-		client := &http.Client{
-			Transport: &headerRoundTripper{
-				headers: m.ResolvedHeaders(),
-			},
-		}
-		return &mcp.StreamableClientTransport{
-			Endpoint:   m.URL,
-			HTTPClient: client,
-		}, nil
-	case config.MCPSSE:
-		if strings.TrimSpace(m.URL) == "" {
-			return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
-		}
-		client := &http.Client{
-			Transport: &headerRoundTripper{
-				headers: m.ResolvedHeaders(),
-			},
-		}
-		return &mcp.SSEClientTransport{
-			Endpoint:   m.URL,
-			HTTPClient: client,
-		}, nil
-	default:
-		return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
-	}
-}
-
-type headerRoundTripper struct {
-	headers map[string]string
-}
-
-func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
-	for k, v := range rt.headers {
-		req.Header.Set(k, v)
-	}
-	return http.DefaultTransport.RoundTrip(req)
-}
-
-func mcpTimeout(m config.MCPConfig) time.Duration {
-	return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second
-}
-
-func stdioMCPCheck(old *exec.Cmd) error {
-	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
-	defer cancel()
-	cmd := exec.CommandContext(ctx, old.Path, old.Args...)
-	cmd.Env = old.Env
-	out, err := cmd.CombinedOutput()
-	if err == nil || errors.Is(ctx.Err(), context.DeadlineExceeded) {
-		return nil
+		return fantasy.NewTextErrorResponse(err.Error()), nil
 	}
-	return fmt.Errorf("%w: %s", err, string(out))
+	return fantasy.NewTextResponse(content), nil
 }

+ 405 - 0
internal/agent/tools/mcp/init.go

@@ -0,0 +1,405 @@
+// Package mcp provides functionality for managing Model Context Protocol (MCP)
+// clients within the Crush application.
+package mcp
+
+import (
+	"cmp"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"maps"
+	"net/http"
+	"os"
+	"os/exec"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/version"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+var (
+	sessions = csync.NewMap[string, *mcp.ClientSession]()
+	states   = csync.NewMap[string, ClientInfo]()
+	broker   = pubsub.NewBroker[Event]()
+)
+
+// State represents the current state of an MCP client
+type State int
+
+const (
+	StateDisabled State = iota
+	StateStarting
+	StateConnected
+	StateError
+)
+
+func (s State) String() string {
+	switch s {
+	case StateDisabled:
+		return "disabled"
+	case StateStarting:
+		return "starting"
+	case StateConnected:
+		return "connected"
+	case StateError:
+		return "error"
+	default:
+		return "unknown"
+	}
+}
+
+// EventType represents the type of MCP event
+type EventType uint
+
+const (
+	EventStateChanged EventType = iota
+	EventToolsListChanged
+	EventPromptsListChanged
+)
+
+// Event represents an event in the MCP system
+type Event struct {
+	Type   EventType
+	Name   string
+	State  State
+	Error  error
+	Counts Counts
+}
+
+// Counts number of available tools, prompts, etc.
+type Counts struct {
+	Tools   int
+	Prompts int
+}
+
+// ClientInfo holds information about an MCP client's state
+type ClientInfo struct {
+	Name        string
+	State       State
+	Error       error
+	Client      *mcp.ClientSession
+	Counts      Counts
+	ConnectedAt time.Time
+}
+
+// SubscribeEvents returns a channel for MCP events
+func SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
+	return broker.Subscribe(ctx)
+}
+
+// GetStates returns the current state of all MCP clients
+func GetStates() map[string]ClientInfo {
+	return maps.Collect(states.Seq2())
+}
+
+// GetState returns the state of a specific MCP client
+func GetState(name string) (ClientInfo, bool) {
+	return states.Get(name)
+}
+
+// Close closes all MCP clients. This should be called during application shutdown.
+func Close() error {
+	var errs []error
+	for name, c := range sessions.Seq2() {
+		if err := c.Close(); err != nil &&
+			!errors.Is(err, io.EOF) &&
+			!errors.Is(err, context.Canceled) &&
+			err.Error() != "signal: killed" {
+			errs = append(errs, fmt.Errorf("close mcp: %s: %w", name, err))
+		}
+	}
+	broker.Shutdown()
+	return errors.Join(errs...)
+}
+
+// Initialize initializes MCP clients based on the provided configuration.
+func Initialize(ctx context.Context, permissions permission.Service, cfg *config.Config) {
+	var wg sync.WaitGroup
+	// Initialize states for all configured MCPs
+	for name, m := range cfg.MCP {
+		if m.Disabled {
+			updateState(name, StateDisabled, nil, nil, Counts{})
+			slog.Debug("skipping disabled mcp", "name", name)
+			continue
+		}
+
+		// Set initial starting state
+		updateState(name, StateStarting, nil, nil, Counts{})
+
+		wg.Add(1)
+		go func(name string, m config.MCPConfig) {
+			defer func() {
+				wg.Done()
+				if r := recover(); r != nil {
+					var err error
+					switch v := r.(type) {
+					case error:
+						err = v
+					case string:
+						err = fmt.Errorf("panic: %s", v)
+					default:
+						err = fmt.Errorf("panic: %v", v)
+					}
+					updateState(name, StateError, err, nil, Counts{})
+					slog.Error("panic in mcp client initialization", "error", err, "name", name)
+				}
+			}()
+
+			ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
+			defer cancel()
+
+			session, err := createSession(ctx, name, m, cfg.Resolver())
+			if err != nil {
+				return
+			}
+
+			tools, err := getTools(ctx, session)
+			if err != nil {
+				slog.Error("error listing tools", "error", err)
+				updateState(name, StateError, err, nil, Counts{})
+				session.Close()
+				return
+			}
+
+			prompts, err := getPrompts(ctx, session)
+			if err != nil {
+				slog.Error("error listing prompts", "error", err)
+				updateState(name, StateError, err, nil, Counts{})
+				session.Close()
+				return
+			}
+
+			updateTools(name, tools)
+			updatePrompts(name, prompts)
+			sessions.Set(name, session)
+
+			updateState(name, StateConnected, nil, session, Counts{
+				Tools:   len(tools),
+				Prompts: len(prompts),
+			})
+		}(name, m)
+	}
+	wg.Wait()
+}
+
+func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) {
+	sess, ok := sessions.Get(name)
+	if !ok {
+		return nil, fmt.Errorf("mcp '%s' not available", name)
+	}
+
+	cfg := config.Get()
+	m := cfg.MCP[name]
+	state, _ := states.Get(name)
+
+	timeout := mcpTimeout(m)
+	pingCtx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+	err := sess.Ping(pingCtx, nil)
+	if err == nil {
+		return sess, nil
+	}
+	updateState(name, StateError, maybeTimeoutErr(err, timeout), nil, state.Counts)
+
+	sess, err = createSession(ctx, name, m, cfg.Resolver())
+	if err != nil {
+		return nil, err
+	}
+
+	updateState(name, StateConnected, nil, sess, state.Counts)
+	sessions.Set(name, sess)
+	return sess, nil
+}
+
+// updateState updates the state of an MCP client and publishes an event
+func updateState(name string, state State, err error, client *mcp.ClientSession, counts Counts) {
+	info := ClientInfo{
+		Name:   name,
+		State:  state,
+		Error:  err,
+		Client: client,
+		Counts: counts,
+	}
+	switch state {
+	case StateConnected:
+		info.ConnectedAt = time.Now()
+	case StateError:
+		updateTools(name, nil)
+		sessions.Del(name)
+	}
+	states.Set(name, info)
+
+	// Publish state change event
+	broker.Publish(pubsub.UpdatedEvent, Event{
+		Type:   EventStateChanged,
+		Name:   name,
+		State:  state,
+		Error:  err,
+		Counts: counts,
+	})
+}
+
+func createSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*mcp.ClientSession, error) {
+	timeout := mcpTimeout(m)
+	mcpCtx, cancel := context.WithCancel(ctx)
+	cancelTimer := time.AfterFunc(timeout, cancel)
+
+	transport, err := createTransport(mcpCtx, m, resolver)
+	if err != nil {
+		updateState(name, StateError, err, nil, Counts{})
+		slog.Error("error creating mcp client", "error", err, "name", name)
+		cancel()
+		cancelTimer.Stop()
+		return nil, err
+	}
+
+	client := mcp.NewClient(
+		&mcp.Implementation{
+			Name:    "crush",
+			Version: version.Version,
+			Title:   "Crush",
+		},
+		&mcp.ClientOptions{
+			ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {
+				broker.Publish(pubsub.UpdatedEvent, Event{
+					Type: EventToolsListChanged,
+					Name: name,
+				})
+			},
+			PromptListChangedHandler: func(context.Context, *mcp.PromptListChangedRequest) {
+				broker.Publish(pubsub.UpdatedEvent, Event{
+					Type: EventPromptsListChanged,
+					Name: name,
+				})
+			},
+			LoggingMessageHandler: func(_ context.Context, req *mcp.LoggingMessageRequest) {
+				slog.Info("mcp log", "name", name, "data", req.Params.Data)
+			},
+			KeepAlive: time.Minute * 10,
+		},
+	)
+
+	session, err := client.Connect(mcpCtx, transport, nil)
+	if err != nil {
+		err = maybeStdioErr(err, transport)
+		updateState(name, StateError, maybeTimeoutErr(err, timeout), nil, Counts{})
+		slog.Error("error starting mcp client", "error", err, "name", name)
+		cancel()
+		cancelTimer.Stop()
+		return nil, err
+	}
+
+	cancelTimer.Stop()
+	slog.Info("Initialized mcp client", "name", name)
+	return session, nil
+}
+
+// maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail
+// to parse, and the cli will then close it, causing the EOF error.
+// so, if we got an EOF err, and the transport is STDIO, we try to exec it
+// again with a timeout and collect the output so we can add details to the
+// error.
+// this happens particularly when starting things with npx, e.g. if node can't
+// be found or some other error like that.
+func maybeStdioErr(err error, transport mcp.Transport) error {
+	if !errors.Is(err, io.EOF) {
+		return err
+	}
+	ct, ok := transport.(*mcp.CommandTransport)
+	if !ok {
+		return err
+	}
+	if err2 := stdioCheck(ct.Command); err2 != nil {
+		err = errors.Join(err, err2)
+	}
+	return err
+}
+
+func maybeTimeoutErr(err error, timeout time.Duration) error {
+	if errors.Is(err, context.Canceled) {
+		return fmt.Errorf("timed out after %s", timeout)
+	}
+	return err
+}
+
+func createTransport(ctx context.Context, m config.MCPConfig, resolver config.VariableResolver) (mcp.Transport, error) {
+	switch m.Type {
+	case config.MCPStdio:
+		command, err := resolver.ResolveValue(m.Command)
+		if err != nil {
+			return nil, fmt.Errorf("invalid mcp command: %w", err)
+		}
+		if strings.TrimSpace(command) == "" {
+			return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
+		}
+		cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
+		cmd.Env = append(os.Environ(), m.ResolvedEnv()...)
+		return &mcp.CommandTransport{
+			Command: cmd,
+		}, nil
+	case config.MCPHttp:
+		if strings.TrimSpace(m.URL) == "" {
+			return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
+		}
+		client := &http.Client{
+			Transport: &headerRoundTripper{
+				headers: m.ResolvedHeaders(),
+			},
+		}
+		return &mcp.StreamableClientTransport{
+			Endpoint:   m.URL,
+			HTTPClient: client,
+		}, nil
+	case config.MCPSSE:
+		if strings.TrimSpace(m.URL) == "" {
+			return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
+		}
+		client := &http.Client{
+			Transport: &headerRoundTripper{
+				headers: m.ResolvedHeaders(),
+			},
+		}
+		return &mcp.SSEClientTransport{
+			Endpoint:   m.URL,
+			HTTPClient: client,
+		}, nil
+	default:
+		return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
+	}
+}
+
+type headerRoundTripper struct {
+	headers map[string]string
+}
+
+func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+	for k, v := range rt.headers {
+		req.Header.Set(k, v)
+	}
+	return http.DefaultTransport.RoundTrip(req)
+}
+
+func mcpTimeout(m config.MCPConfig) time.Duration {
+	return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second
+}
+
+func stdioCheck(old *exec.Cmd) error {
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, old.Path, old.Args...)
+	cmd.Env = old.Env
+	out, err := cmd.CombinedOutput()
+	if err == nil || errors.Is(ctx.Err(), context.DeadlineExceeded) {
+		return nil
+	}
+	return fmt.Errorf("%w: %s", err, string(out))
+}

+ 87 - 0
internal/agent/tools/mcp/prompts.go

@@ -0,0 +1,87 @@
+package mcp
+
+import (
+	"context"
+	"iter"
+	"log/slog"
+
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+type Prompt = mcp.Prompt
+
+var allPrompts = csync.NewMap[string, []*Prompt]()
+
+// Prompts returns all available MCP prompts.
+func Prompts() iter.Seq2[string, []*Prompt] {
+	return allPrompts.Seq2()
+}
+
+// GetPromptMessages retrieves the content of an MCP prompt with the given arguments.
+func GetPromptMessages(ctx context.Context, clientName, promptName string, args map[string]string) ([]string, error) {
+	c, err := getOrRenewClient(ctx, clientName)
+	if err != nil {
+		return nil, err
+	}
+	result, err := c.GetPrompt(ctx, &mcp.GetPromptParams{
+		Name:      promptName,
+		Arguments: args,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	var messages []string
+	for _, msg := range result.Messages {
+		if msg.Role != "user" {
+			continue
+		}
+		if textContent, ok := msg.Content.(*mcp.TextContent); ok {
+			messages = append(messages, textContent.Text)
+		}
+	}
+	return messages, nil
+}
+
+// RefreshPrompts gets the updated list of prompts from the MCP and updates the
+// global state.
+func RefreshPrompts(ctx context.Context, name string) {
+	session, ok := sessions.Get(name)
+	if !ok {
+		slog.Warn("refresh prompts: no session", "name", name)
+		return
+	}
+
+	prompts, err := getPrompts(ctx, session)
+	if err != nil {
+		updateState(name, StateError, err, nil, Counts{})
+		return
+	}
+
+	updatePrompts(name, prompts)
+
+	prev, _ := states.Get(name)
+	prev.Counts.Prompts = len(prompts)
+	updateState(name, StateConnected, nil, session, prev.Counts)
+}
+
+func getPrompts(ctx context.Context, c *mcp.ClientSession) ([]*Prompt, error) {
+	if c.InitializeResult().Capabilities.Prompts == nil {
+		return nil, nil
+	}
+	result, err := c.ListPrompts(ctx, &mcp.ListPromptsParams{})
+	if err != nil {
+		return nil, err
+	}
+	return result.Prompts, nil
+}
+
+// updatePrompts updates the global mcpPrompts and mcpClient2Prompts maps
+func updatePrompts(mcpName string, prompts []*Prompt) {
+	if len(prompts) == 0 {
+		allPrompts.Del(mcpName)
+		return
+	}
+	allPrompts.Set(mcpName, prompts)
+}

+ 93 - 0
internal/agent/tools/mcp/tools.go

@@ -0,0 +1,93 @@
+package mcp
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"iter"
+	"log/slog"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+type Tool = mcp.Tool
+
+var allTools = csync.NewMap[string, []*Tool]()
+
+// Tools returns all available MCP tools.
+func Tools() iter.Seq2[string, []*Tool] {
+	return allTools.Seq2()
+}
+
+// RunTool runs an MCP tool with the given input parameters.
+func RunTool(ctx context.Context, name, toolName string, input string) (string, error) {
+	var args map[string]any
+	if err := json.Unmarshal([]byte(input), &args); err != nil {
+		return "", fmt.Errorf("error parsing parameters: %s", err)
+	}
+
+	c, err := getOrRenewClient(ctx, name)
+	if err != nil {
+		return "", err
+	}
+	result, err := c.CallTool(ctx, &mcp.CallToolParams{
+		Name:      toolName,
+		Arguments: args,
+	})
+	if err != nil {
+		return "", err
+	}
+
+	output := make([]string, 0, len(result.Content))
+	for _, v := range result.Content {
+		if vv, ok := v.(*mcp.TextContent); ok {
+			output = append(output, vv.Text)
+		} else {
+			output = append(output, fmt.Sprintf("%v", v))
+		}
+	}
+	return strings.Join(output, "\n"), nil
+}
+
+// RefreshTools gets the updated list of tools from the MCP and updates the
+// global state.
+func RefreshTools(ctx context.Context, name string) {
+	session, ok := sessions.Get(name)
+	if !ok {
+		slog.Warn("refresh tools: no session", "name", name)
+		return
+	}
+
+	tools, err := getTools(ctx, session)
+	if err != nil {
+		updateState(name, StateError, err, nil, Counts{})
+		return
+	}
+
+	updateTools(name, tools)
+
+	prev, _ := states.Get(name)
+	prev.Counts.Tools = len(tools)
+	updateState(name, StateConnected, nil, session, prev.Counts)
+}
+
+func getTools(ctx context.Context, session *mcp.ClientSession) ([]*Tool, error) {
+	if session.InitializeResult().Capabilities.Tools == nil {
+		return nil, nil
+	}
+	result, err := session.ListTools(ctx, &mcp.ListToolsParams{})
+	if err != nil {
+		return nil, err
+	}
+	return result.Tools, nil
+}
+
+func updateTools(name string, tools []*Tool) {
+	if len(tools) == 0 {
+		allTools.Del(name)
+		return
+	}
+	allTools.Set(name, tools)
+}

+ 6 - 1
internal/agent/tools/view.go

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

+ 72 - 0
internal/agent/tools/web_fetch.go

@@ -0,0 +1,72 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"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 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
+		})
+}

+ 28 - 0
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>

+ 61 - 18
internal/app/app.go

@@ -1,3 +1,5 @@
+// Package app wires together services, coordinates agents, and manages
+// application lifecycle.
 package app
 
 import (
@@ -5,14 +7,17 @@ import (
 	"database/sql"
 	"errors"
 	"fmt"
+	"io"
 	"log/slog"
+	"os"
 	"sync"
 	"time"
 
+	tea "charm.land/bubbletea/v2"
 	"charm.land/fantasy"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/agent"
-	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/db"
@@ -24,7 +29,11 @@ import (
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/term"
+	"github.com/charmbracelet/crush/internal/tui/components/anim"
+	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/exp/charmtone"
 )
 
 type App struct {
@@ -82,8 +91,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 	// Initialize LSP clients in the background.
 	app.initLSPClients(ctx)
 
+	go func() {
+		slog.Info("Initializing MCP clients")
+		mcp.Initialize(ctx, app.Permissions, cfg)
+	}()
+
 	// cleanup database upon app shutdown
-	app.cleanupFuncs = append(app.cleanupFuncs, conn.Close)
+	app.cleanupFuncs = append(app.cleanupFuncs, conn.Close, mcp.Close)
 
 	// TODO: remove the concept of agent config, most likely.
 	if !cfg.IsConfigured() {
@@ -101,9 +115,9 @@ func (app *App) Config() *config.Config {
 	return app.config
 }
 
-// RunNonInteractive handles the execution flow when a prompt is provided via
-// CLI flag.
-func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool) error {
+// RunNonInteractive runs the application in non-interactive mode with the
+// given prompt, printing to stdout.
+func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt string, quiet bool) error {
 	slog.Info("Running in non-interactive mode")
 
 	ctx, cancel := context.WithCancel(ctx)
@@ -111,7 +125,25 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 
 	var spinner *format.Spinner
 	if !quiet {
-		spinner = format.NewSpinner(ctx, cancel, "Generating")
+		t := styles.CurrentTheme()
+
+		// Detect background color to set the appropriate color for the
+		// spinner's 'Generating...' text. Without this, that text would be
+		// unreadable in light terminals.
+		hasDarkBG := true
+		if f, ok := output.(*os.File); ok {
+			hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, f)
+		}
+		defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
+
+		spinner = format.NewSpinner(ctx, cancel, anim.Settings{
+			Size:        10,
+			Label:       "Generating",
+			LabelColor:  defaultFG,
+			GradColorA:  t.Primary,
+			GradColorB:  t.Secondary,
+			CycleColors: true,
+		})
 		spinner.Start()
 	}
 
@@ -125,7 +157,7 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 	defer stopSpinner()
 
 	const maxPromptLengthForTitle = 100
-	titlePrefix := "Non-interactive: "
+	const titlePrefix = "Non-interactive: "
 	var titleSuffix string
 
 	if len(prompt) > maxPromptLengthForTitle {
@@ -141,7 +173,8 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 	}
 	slog.Info("Created session for non-interactive run", "session_id", sess.ID)
 
-	// Automatically approve all permission requests for this non-interactive session
+	// Automatically approve all permission requests for this non-interactive
+	// session.
 	app.Permissions.AutoApproveSession(sess.ID)
 
 	type response struct {
@@ -164,12 +197,25 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 
 	messageEvents := app.Messages.Subscribe(ctx)
 	messageReadBytes := make(map[string]int)
+	supportsProgressBar := term.SupportsProgressBar()
+
+	defer func() {
+		if supportsProgressBar {
+			_, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
+		}
+
+		// Always print a newline at the end. If output is a TTY this will
+		// prevent the prompt from overwriting the last line of output.
+		_, _ = fmt.Fprintln(output)
+	}()
 
-	defer fmt.Printf(ansi.ResetProgressBar)
 	for {
-		// HACK: add it again on every iteration so it doesn't get hidden by
-		// the terminal due to inactivity.
-		fmt.Printf(ansi.SetIndeterminateProgressBar)
+		if supportsProgressBar {
+			// HACK: Reinitialize the terminal progress bar on every iteration so
+			// it doesn't get hidden by the terminal due to inactivity.
+			_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
+		}
+
 		select {
 		case result := <-done:
 			stopSpinner()
@@ -196,7 +242,7 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 				}
 
 				part := content[readBytes:]
-				fmt.Print(part)
+				fmt.Fprint(output, part)
 				messageReadBytes[msg.ID] = len(content)
 			}
 
@@ -219,7 +265,7 @@ func (app *App) setupEvents() {
 	setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
 	setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
 	setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
-	setupSubscriber(ctx, app.serviceEventsWG, "mcp", tools.SubscribeMCPEvents, app.events)
+	setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
 	setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
 	cleanupFunc := func() error {
 		cancel()
@@ -281,9 +327,6 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
 		slog.Error("Failed to create coder agent", "err", err)
 		return err
 	}
-
-	// Add MCP client cleanup to shutdown process
-	app.cleanupFuncs = append(app.cleanupFuncs, tools.CloseMCPClients)
 	return nil
 }
 

+ 2 - 2
internal/cmd/dirs.go

@@ -4,9 +4,9 @@ import (
 	"os"
 	"path/filepath"
 
+	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/table"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/charmbracelet/lipgloss/v2/table"
 	"github.com/charmbracelet/x/term"
 	"github.com/spf13/cobra"
 )

+ 14 - 5
internal/cmd/root.go

@@ -12,19 +12,21 @@ import (
 	"strconv"
 	"strings"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/colorprofile"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/db"
 	"github.com/charmbracelet/crush/internal/event"
+	termutil "github.com/charmbracelet/crush/internal/term"
 	"github.com/charmbracelet/crush/internal/tui"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	ui "github.com/charmbracelet/crush/internal/ui/model"
 	"github.com/charmbracelet/crush/internal/version"
 	"github.com/charmbracelet/fang"
-	"github.com/charmbracelet/lipgloss/v2"
 	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/charmtone"
 	"github.com/charmbracelet/x/term"
 	"github.com/spf13/cobra"
@@ -34,7 +36,6 @@ func init() {
 	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
-
 	rootCmd.Flags().BoolP("help", "h", false, "Help")
 	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 
@@ -76,7 +77,7 @@ crush run "Explain the use of context in Go"
 crush -y
   `,
 	RunE: func(cmd *cobra.Command, args []string) error {
-		app, err := setupApp(cmd)
+		app, err := setupAppWithProgressBar(cmd)
 		if err != nil {
 			return err
 		}
@@ -94,7 +95,6 @@ crush -y
 			tea.WithEnvironment(env),
 			tea.WithContext(cmd.Context()),
 			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
-
 		go app.Subscribe(program)
 
 		if _, err := program.Run(); err != nil {
@@ -152,6 +152,15 @@ func Execute() {
 	}
 }
 
+func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
+	if termutil.SupportsProgressBar() {
+		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
+		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
+	}
+
+	return setupApp(cmd)
+}
+
 // setupApp handles the common setup logic for both interactive and non-interactive modes.
 // It returns the app instance, config, cleanup function, and any error.
 func setupApp(cmd *cobra.Command) (*app.App, error) {

+ 14 - 5
internal/cmd/run.go

@@ -3,6 +3,7 @@ package cmd
 import (
 	"fmt"
 	"log/slog"
+	"os"
 	"strings"
 
 	"github.com/spf13/cobra"
@@ -18,10 +19,13 @@ The prompt can be provided as arguments or piped from stdin.`,
 crush run Explain the use of context in Go
 
 # Pipe input from stdin
-echo "What is this code doing?" | crush run
+curl https://charm.land | crush run "Summarize this website"
 
-# Run with quiet mode (no spinner)
-crush run -q "Generate a README for this project"
+# Read from a file
+crush run "What is this code doing?" <<< prrr.go
+
+# Run in quiet mode (hide the spinner)
+crush run --quiet "Generate a README for this project"
   `,
 	RunE: func(cmd *cobra.Command, args []string) error {
 		quiet, _ := cmd.Flags().GetBool("quiet")
@@ -48,8 +52,13 @@ crush run -q "Generate a README for this project"
 			return fmt.Errorf("no prompt provided")
 		}
 
-		// Run non-interactive flow using the App method
-		return app.RunNonInteractive(cmd.Context(), prompt, quiet)
+		// TODO: Make this work when redirected to something other than stdout.
+		// For example:
+		//     crush run "Do something fancy" > output.txt
+		//     echo "Do something fancy" | crush run > output.txt
+		//
+		// TODO: We currently need to press ^c twice to cancel. Fix that.
+		return app.RunNonInteractive(cmd.Context(), os.Stdout, prompt, quiet)
 	},
 }
 

+ 1 - 1
internal/cmd/update_providers.go

@@ -4,8 +4,8 @@ import (
 	"fmt"
 	"log/slog"
 
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/exp/charmtone"
 	"github.com/spf13/cobra"
 )

+ 1 - 0
internal/config/config.go

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

+ 0 - 11
internal/config/load.go

@@ -662,17 +662,6 @@ func GlobalConfig() string {
 		return filepath.Join(xdgConfigHome, appName, fmt.Sprintf("%s.json", appName))
 	}
 
-	// return the path to the main config directory
-	// for windows, it should be in `%LOCALAPPDATA%/crush/`
-	// for linux and macOS, it should be in `$HOME/.config/crush/`
-	if runtime.GOOS == "windows" {
-		localAppData := os.Getenv("LOCALAPPDATA")
-		if localAppData == "" {
-			localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
-		}
-		return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
-	}
-
 	return filepath.Join(home.Dir(), ".config", appName, fmt.Sprintf("%s.json", appName))
 }
 

+ 2 - 2
internal/config/load_test.go

@@ -485,7 +485,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 	cfg.SetupAgents()
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
-	assert.Equal(t, []string{"agent", "bash", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
@@ -508,7 +508,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 	cfg.SetupAgents()
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
-	assert.Equal(t, []string{"agent", "bash", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "write"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)

+ 4 - 14
internal/config/provider.go

@@ -10,7 +10,6 @@ import (
 	"runtime"
 	"strings"
 	"sync"
-	"time"
 
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/catwalk/pkg/embedded"
@@ -49,7 +48,7 @@ func providerCacheFileData() string {
 }
 
 func saveProvidersInCache(path string, providers []catwalk.Provider) error {
-	slog.Info("Saving cached provider data", "path", path)
+	slog.Info("Saving provider data to disk", "path", path)
 	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
 		return fmt.Errorf("failed to create directory for provider cache: %w", err)
 	}
@@ -126,8 +125,6 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) {
 }
 
 func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) {
-	_, cacheExists := isCacheStale(path)
-
 	catwalkGetAndSave := func() ([]catwalk.Provider, error) {
 		providers, err := client.GetProviders()
 		if err != nil {
@@ -141,11 +138,12 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
 		}
 		return providers, nil
 	}
+
 	switch {
 	case autoUpdateDisabled:
 		slog.Warn("Providers auto-update is disabled")
 
-		if cacheExists {
+		if _, err := os.Stat(path); err == nil {
 			slog.Warn("Using locally cached providers")
 			return loadProvidersFromCache(path)
 		}
@@ -158,7 +156,7 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
 		return providers, nil
 
 	default:
-		slog.Info("Cache is not available or is stale. Fetching providers from Catwalk.", "path", path)
+		slog.Info("Fetching providers from Catwalk.", "path", path)
 
 		providers, err := catwalkGetAndSave()
 		if err != nil {
@@ -168,11 +166,3 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
 		return providers, nil
 	}
 }
-
-func isCacheStale(path string) (stale, exists bool) {
-	info, err := os.Stat(path)
-	if err != nil {
-		return true, false
-	}
-	return time.Since(info.ModTime()) > 24*time.Hour, true
-}

+ 1 - 1
internal/db/connect.go

@@ -23,7 +23,7 @@ func Connect(ctx context.Context, dataDir string) (*sql.DB, error) {
 	// Set pragmas for better performance
 	pragmas := []string{
 		"PRAGMA foreign_keys = ON;",
-		"PRAGMA journal_mode = WAL;",
+		"PRAGMA journal_mode = DELETE;",
 		"PRAGMA page_size = 4096;",
 		"PRAGMA cache_size = -8000;",
 		"PRAGMA synchronous = NORMAL;",

+ 6 - 19
internal/format/spinner.go

@@ -6,9 +6,8 @@ import (
 	"fmt"
 	"os"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
-	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/x/ansi"
 )
 
@@ -42,28 +41,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // NewSpinner creates a new spinner with the given message
-func NewSpinner(ctx context.Context, cancel context.CancelFunc, message string) *Spinner {
-	t := styles.CurrentTheme()
-	model := model{
-		anim: anim.New(anim.Settings{
-			Size:        10,
-			Label:       message,
-			LabelColor:  t.FgBase,
-			GradColorA:  t.Primary,
-			GradColorB:  t.Secondary,
-			CycleColors: true,
-		}),
+func NewSpinner(ctx context.Context, cancel context.CancelFunc, animSettings anim.Settings) *Spinner {
+	m := model{
+		anim:   anim.New(animSettings),
 		cancel: cancel,
 	}
 
-	prog := tea.NewProgram(
-		model,
-		tea.WithOutput(os.Stderr),
-		tea.WithContext(ctx),
-	)
+	p := tea.NewProgram(m, tea.WithOutput(os.Stderr), tea.WithContext(ctx))
 
 	return &Spinner{
-		prog: prog,
+		prog: p,
 		done: make(chan struct{}, 1),
 	}
 }

+ 15 - 20
internal/home/home.go

@@ -6,38 +6,33 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
-	"sync"
 )
 
-// Dir returns the users home directory, or if it fails, tries to create a new
-// temporary directory and use that instead.
-var Dir = sync.OnceValue(func() string {
-	home, err := os.UserHomeDir()
-	if err == nil {
-		slog.Debug("user home directory", "home", home)
-		return home
-	}
-	tmp, err := os.MkdirTemp("crush", "")
-	if err != nil {
-		slog.Error("could not find the user home directory")
-		return ""
+var homedir, homedirErr = os.UserHomeDir()
+
+func init() {
+	if homedirErr != nil {
+		slog.Error("failed to get user home directory", "error", homedirErr)
 	}
-	slog.Warn("could not find the user home directory, using a temporary one", "home", tmp)
-	return tmp
-})
+}
+
+// Dir returns the user home directory.
+func Dir() string {
+	return homedir
+}
 
 // Short replaces the actual home path from [Dir] with `~`.
 func Short(p string) string {
-	if !strings.HasPrefix(p, Dir()) || Dir() == "" {
+	if homedir == "" || !strings.HasPrefix(p, homedir) {
 		return p
 	}
-	return filepath.Join("~", strings.TrimPrefix(p, Dir()))
+	return filepath.Join("~", strings.TrimPrefix(p, homedir))
 }
 
 // Long replaces the `~` with actual home path from [Dir].
 func Long(p string) string {
-	if !strings.HasPrefix(p, "~") || Dir() == "" {
+	if homedir == "" || !strings.HasPrefix(p, "~") {
 		return p
 	}
-	return strings.Replace(p, "~", Dir(), 1)
+	return strings.Replace(p, "~", homedir, 1)
 }

+ 1 - 1
internal/permission/permission.go

@@ -13,7 +13,7 @@ import (
 	"github.com/google/uuid"
 )
 
-var ErrorPermissionDenied = errors.New("permission denied")
+var ErrorPermissionDenied = errors.New("user denied permission")
 
 type CreatePermissionRequest struct {
 	SessionID   string `json:"session_id"`

+ 15 - 0
internal/term/term.go

@@ -0,0 +1,15 @@
+package term
+
+import (
+	"os"
+	"strings"
+)
+
+// SupportsProgressBar tries to determine whether the current terminal supports
+// progress bars by looking into environment variables.
+func SupportsProgressBar() bool {
+	termProg := os.Getenv("TERM_PROGRAM")
+	_, isWindowsTerminal := os.LookupEnv("WT_SESSION")
+
+	return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
+}

+ 2 - 2
internal/tui/components/anim/anim.go

@@ -11,8 +11,8 @@ import (
 
 	"github.com/zeebo/xxh3"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/lucasb-eyer/go-colorful"
 
 	"github.com/charmbracelet/crush/internal/csync"

+ 5 - 4
internal/tui/components/chat/chat.go

@@ -5,10 +5,11 @@ import (
 	"strings"
 	"time"
 
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
 	"github.com/atotto/clipboard"
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
@@ -635,8 +636,8 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
 	for _, tc := range msg.ToolCalls() {
 		options := m.buildToolCallOptions(tc, msg, toolResultMap)
 		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
-		// If this tool call is the agent tool, fetch nested tool calls
-		if tc.Name == agent.AgentToolName {
+		// If this tool call is the agent tool or agentic fetch, fetch nested tool calls
+		if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
 			agentToolSessionID := m.app.Sessions.CreateAgentToolSessionID(msg.ID, tc.ID)
 			nestedMessages, _ := m.app.Messages.List(context.Background(), agentToolSessionID)
 			nestedToolResultMap := m.buildToolResultMap(nestedMessages)

+ 12 - 7
internal/tui/components/chat/editor/editor.go

@@ -13,9 +13,10 @@ import (
 	"strings"
 	"unicode"
 
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/textarea"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textarea"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/message"
@@ -29,7 +30,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
 type Editor interface {
@@ -220,7 +220,7 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		m.textarea.SetValue(msg.Text)
 		m.textarea.MoveToEnd()
 	case tea.PasteMsg:
-		path := strings.ReplaceAll(string(msg), "\\ ", " ")
+		path := strings.ReplaceAll(msg.Content, "\\ ", " ")
 		// try to get an image
 		path, err := filepath.Abs(strings.TrimSpace(path))
 		if err != nil {
@@ -264,8 +264,13 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		cur := m.textarea.Cursor()
 		curIdx := m.textarea.Width()*cur.Y + cur.X
 		switch {
+		// Open command palette when "/" is pressed on empty prompt
+		case msg.String() == "/" && len(strings.TrimSpace(m.textarea.Value())) == 0:
+			return m, util.CmdHandler(dialogs.OpenDialogMsg{
+				Model: commands.NewCommandDialog(m.session.ID),
+			})
 		// Completions
-		case msg.String() == "/" && !m.isCompletionsOpen &&
+		case msg.String() == "@" && !m.isCompletionsOpen &&
 			// only show if beginning of prompt, or if previous char is a space or newline:
 			(len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
 			m.isCompletionsOpen = true
@@ -337,7 +342,7 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
 			} else {
 				word := m.textarea.Word()
-				if strings.HasPrefix(word, "/") {
+				if strings.HasPrefix(word, "@") {
 					// XXX: wont' work if editing in the middle of the field.
 					m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
 					m.currentQuery = word[1:]

+ 1 - 1
internal/tui/components/chat/editor/keys.go

@@ -1,7 +1,7 @@
 package editor
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 type EditorKeyMap struct {

+ 2 - 2
internal/tui/components/chat/header/header.go

@@ -4,7 +4,8 @@ import (
 	"fmt"
 	"strings"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/fsext"
@@ -13,7 +14,6 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )

+ 26 - 15
internal/tui/components/chat/messages/messages.go

@@ -6,11 +6,11 @@ import (
 	"strings"
 	"time"
 
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/viewport"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/viewport"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/ordered"
 	"github.com/google/uuid"
@@ -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
 }
 

+ 166 - 26
internal/tui/components/chat/messages/renderer.go

@@ -6,6 +6,8 @@ import (
 	"strings"
 	"time"
 
+	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/tree"
 	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/ansiext"
@@ -13,8 +15,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/highlight"
 	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/charmbracelet/lipgloss/v2/tree"
 	"github.com/charmbracelet/x/ansi"
 )
 
@@ -168,7 +168,9 @@ func init() {
 	registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
 	registry.register(tools.MultiEditToolName, func() renderer { return multiEditRenderer{} })
 	registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
-	registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} })
+	registry.register(tools.FetchToolName, func() renderer { return simpleFetchRenderer{} })
+	registry.register(tools.AgenticFetchToolName, func() renderer { return agenticFetchRenderer{} })
+	registry.register(tools.WebFetchToolName, func() renderer { return webFetchRenderer{} })
 	registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
 	registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
 	registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
@@ -407,13 +409,13 @@ func (wr writeRenderer) Render(v *toolCallCmp) string {
 //  Fetch renderer
 // -----------------------------------------------------------------------------
 
-// fetchRenderer handles URL fetching with format-specific content display
-type fetchRenderer struct {
+// simpleFetchRenderer handles URL fetching with format-specific content display
+type simpleFetchRenderer struct {
 	baseRenderer
 }
 
 // Render displays the fetched URL with format and timeout parameters
-func (fr fetchRenderer) Render(v *toolCallCmp) string {
+func (fr simpleFetchRenderer) Render(v *toolCallCmp) string {
 	var params tools.FetchParams
 	var args []string
 	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
@@ -431,7 +433,7 @@ func (fr fetchRenderer) Render(v *toolCallCmp) string {
 }
 
 // getFileExtension returns appropriate file extension for syntax highlighting
-func (fr fetchRenderer) getFileExtension(format string) string {
+func (fr simpleFetchRenderer) getFileExtension(format string) string {
 	switch format {
 	case "text":
 		return "fetch.txt"
@@ -442,6 +444,78 @@ func (fr fetchRenderer) getFileExtension(format string) string {
 	}
 }
 
+// -----------------------------------------------------------------------------
+//  Agentic fetch renderer
+// -----------------------------------------------------------------------------
+
+// agenticFetchRenderer handles URL fetching with prompt parameter and nested tool calls
+type agenticFetchRenderer struct {
+	baseRenderer
+}
+
+// Render displays the fetched URL with prompt parameter and nested tool calls
+func (fr agenticFetchRenderer) Render(v *toolCallCmp) string {
+	t := styles.CurrentTheme()
+	var params tools.AgenticFetchParams
+	var args []string
+	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
+		args = newParamBuilder().
+			addMain(params.URL).
+			build()
+	}
+
+	prompt := params.Prompt
+	prompt = strings.ReplaceAll(prompt, "\n", " ")
+
+	header := fr.makeHeader(v, "Agentic Fetch", v.textWidth(), args...)
+	if res, done := earlyState(header, v); v.cancelled && done {
+		return res
+	}
+
+	taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.GreenLight).Foreground(t.Border).Render("Prompt")
+	remainingWidth := v.textWidth() - (lipgloss.Width(taskTag) + 1)
+	remainingWidth = min(remainingWidth, 120-(lipgloss.Width(taskTag)+1))
+	prompt = t.S().Base.Width(remainingWidth).Render(prompt)
+	header = lipgloss.JoinVertical(
+		lipgloss.Left,
+		header,
+		"",
+		lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			taskTag,
+			" ",
+			prompt,
+		),
+	)
+	childTools := tree.Root(header)
+
+	for _, call := range v.nestedToolCalls {
+		call.SetSize(remainingWidth, 1)
+		childTools.Child(call.View())
+	}
+	parts := []string{
+		childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).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
 func formatTimeout(timeout int) string {
 	if timeout == 0 {
@@ -450,6 +524,30 @@ 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 {
+	var params tools.WebFetchParams
+	var args []string
+	if err := wfr.unmarshalParams(v.call.Input, &params); err == nil {
+		args = newParamBuilder().
+			addMain(params.URL).
+			build()
+	}
+
+	return wfr.renderWithParams(v, "Fetch", args, func() string {
+		return renderMarkdownContent(v, v.result.Content)
+	})
+}
+
 // -----------------------------------------------------------------------------
 //  Download renderer
 // -----------------------------------------------------------------------------
@@ -609,11 +707,21 @@ type agentRenderer struct {
 	baseRenderer
 }
 
-func RoundedEnumerator(children tree.Children, index int) string {
-	if children.Length()-1 == index {
-		return " ╰──"
+func RoundedEnumeratorWithWidth(lPadding, width int) tree.Enumerator {
+	if width == 0 {
+		width = 2
+	}
+	if lPadding == 0 {
+		lPadding = 1
+	}
+	return func(children tree.Children, index int) string {
+		line := strings.Repeat("─", width)
+		padding := strings.Repeat(" ", lPadding)
+		if children.Length()-1 == index {
+			return padding + "╰" + line
+		}
+		return padding + "├" + line
 	}
-	return " ├──"
 }
 
 // Render displays agent task parameters and result content
@@ -629,8 +737,9 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
 	if res, done := earlyState(header, v); v.cancelled && done {
 		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
+	taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.BlueLight).Foreground(t.White).Render("Task")
+	remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
+	remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
 	prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
 	header = lipgloss.JoinVertical(
 		lipgloss.Left,
@@ -646,10 +755,11 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
 	childTools := tree.Root(header)
 
 	for _, call := range v.nestedToolCalls {
+		call.SetSize(remainingWidth, 1)
 		childTools.Child(call.View())
 	}
 	parts := []string{
-		childTools.Enumerator(RoundedEnumerator).String(),
+		childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
 	}
 
 	if v.result.ToolCallID == "" {
@@ -668,7 +778,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)
 }
 
@@ -684,9 +794,6 @@ func renderParamList(nested bool, paramsWidth int, params ...string) string {
 	}
 
 	if len(params) == 1 {
-		if nested {
-			return t.S().Muted.Render(mainParam)
-		}
 		return t.S().Subtle.Render(mainParam)
 	}
 	otherParams := params[1:]
@@ -708,9 +815,6 @@ func renderParamList(nested bool, paramsWidth int, params ...string) string {
 	partsRendered := strings.Join(parts, ", ")
 	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
 	if remainingWidth < 30 {
-		if nested {
-			return t.S().Muted.Render(mainParam)
-		}
 		// No space for the params, just show the main
 		return t.S().Subtle.Render(mainParam)
 	}
@@ -719,9 +823,6 @@ func renderParamList(nested bool, paramsWidth int, params ...string) string {
 		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
 	}
 
-	if nested {
-		return t.S().Muted.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
-	}
 	return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
 }
 
@@ -764,14 +865,14 @@ 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 // left padding
+		ln = " " + ln
 		if len(ln) > width {
 			ln = v.fit(ln, width)
 		}
@@ -791,6 +892,41 @@ func renderPlainContent(v *toolCallCmp, content string) string {
 	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)
+	rendered, err := renderer.Render(content)
+	if err != nil {
+		return renderPlainContent(v, content)
+	}
+
+	lines := strings.Split(rendered, "\n")
+
+	var out []string
+	for i, ln := range lines {
+		if i >= responseContextHeight {
+			break
+		}
+		out = append(out, ln)
+	}
+
+	style := t.S().Muted.Background(t.BgBaseLighter)
+	if len(lines) > responseContextHeight {
+		out = append(out, style.
+			Width(width-2).
+			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
+	}
+
+	return style.Render(strings.Join(out, "\n"))
+}
+
 func getDigits(n int) int {
 	if n == 0 {
 		return 1
@@ -885,6 +1021,10 @@ func prettifyToolName(name string) string {
 		return "Multi-Edit"
 	case tools.FetchToolName:
 		return "Fetch"
+	case tools.AgenticFetchToolName:
+		return "Agentic Fetch"
+	case tools.WebFetchToolName:
+		return "Fetching"
 	case tools.GlobToolName:
 		return "Glob"
 	case tools.GrepToolName:

+ 66 - 13
internal/tui/components/chat/messages/tool.go

@@ -7,9 +7,10 @@ import (
 	"strings"
 	"time"
 
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/atotto/clipboard"
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/diff"
@@ -20,7 +21,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 )
 
@@ -293,10 +293,25 @@ func (m *toolCallCmp) formatParametersForCopy() string {
 				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()))
+				parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
+			}
+			return strings.Join(parts, "\n")
+		}
+	case tools.AgenticFetchToolName:
+		var params tools.AgenticFetchParams
+		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
+			var parts []string
+			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
+			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 +410,10 @@ func (m *toolCallCmp) formatResultForCopy() string {
 		return m.formatWriteResultForCopy()
 	case tools.FetchToolName:
 		return m.formatFetchResultForCopy()
+	case tools.AgenticFetchToolName:
+		return m.formatAgenticFetchResultForCopy()
+	case tools.WebFetchToolName:
+		return m.formatWebFetchResultForCopy()
 	case agent.AgentToolName:
 		return m.formatAgentResultForCopy()
 	case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName:
@@ -608,15 +627,49 @@ func (m *toolCallCmp) formatFetchResultForCopy() string {
 	if params.URL != "" {
 		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
 	}
+	if params.Format != "" {
+		result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
+	}
+	if params.Timeout > 0 {
+		result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
+	}
+	result.WriteString("\n")
 
-	switch params.Format {
-	case "html":
-		result.WriteString("```html\n")
-	case "text":
-		result.WriteString("```\n")
-	default: // markdown
-		result.WriteString("```markdown\n")
+	result.WriteString(m.result.Content)
+
+	return result.String()
+}
+
+func (m *toolCallCmp) formatAgenticFetchResultForCopy() string {
+	var params tools.AgenticFetchParams
+	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
+		return m.result.Content
 	}
+
+	var result strings.Builder
+	if params.URL != "" {
+		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+	}
+	if params.Prompt != "" {
+		result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
+	}
+
+	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```")
 
@@ -718,10 +771,10 @@ func (m *toolCallCmp) style() lipgloss.Style {
 	if m.isNested {
 		return t.S().Muted
 	}
-	style := t.S().Muted.PaddingLeft(4)
+	style := t.S().Muted.PaddingLeft(2)
 
 	if m.focused {
-		style = style.PaddingLeft(3).BorderStyle(focusedMessageBorder).BorderLeft(true).BorderForeground(t.GreenDark)
+		style = style.PaddingLeft(1).BorderStyle(focusedMessageBorder).BorderLeft(true).BorderForeground(t.GreenDark)
 	}
 	return style
 }

+ 1 - 1
internal/tui/components/chat/queue.go

@@ -4,8 +4,8 @@ import (
 	"fmt"
 	"strings"
 
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
 func queuePill(queue int, t *styles.Theme) string {

+ 2 - 2
internal/tui/components/chat/sidebar/sidebar.go

@@ -6,7 +6,8 @@ import (
 	"slices"
 	"strings"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
@@ -27,7 +28,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/crush/internal/version"
-	"github.com/charmbracelet/lipgloss/v2"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 )

+ 1 - 1
internal/tui/components/chat/splash/keys.go

@@ -1,7 +1,7 @@
 package splash
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 type KeyMap struct {

+ 4 - 4
internal/tui/components/chat/splash/splash.go

@@ -5,9 +5,10 @@ import (
 	"strings"
 	"time"
 
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/spinner"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/spinner"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/config"
@@ -23,7 +24,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/crush/internal/version"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
 type Splash interface {

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است