Repository: charmbracelet/crush

Issue:
SQLite fail on SMB-mounted filesystems
### Describe the bug
Running `crush` with a project directory on an SMB-mounted share causes SQLite migrations to fail with: `Failed to apply migrations: sqlite3: disk I/O error; sqlite3: disk I/O error.`

This only occurs over SMB/CIFS; the same workflow on a local filesystem succeeds. This strongly suggests SQLite’s WAL/journal/locking/fsync behavior is incompatible with common SMB mount defaults. Crush currently assumes a POSIX-local FS and does not provide a way to mitigate this (e.g., redirect DB to a local path or change SQLite PRAGMAs), so users on SMB shares hit a hard failure during startup/migrations.

### Setup
- OS: macOS Sequoia 15.5
  - Hardware: MacBook Pro (13-inch, 2019, Four Thunderbolt 3 ports)
  - CPU: 2.8 GHz Quad‑Core Intel Core i7
  - RAM: 16 GB 2133 MHz LPDDR3
  - GPU: Intel Iris Plus Graphics 655 1536 MB
- Shell: zsh
- Terminal Emulator: iTerm2 (also reproduced in Apple Terminal)
- Locale: UTF-8
- Storage: Project directory on SMB/CIFS share (mounted via Finder/mount_smbfs).

### To Reproduce
1. Mount an SMB share (e.g., macOS Finder or mount_smbfs; on Linux, mount -t cifs with default options).
2. cd into a project directory on the SMB share.
3. Run crush (e.g., crush or crush --debug).
4. Observe error during initialization/migrations:
   Failed to apply migrations: sqlite3: disk I/O error; sqlite3: disk I/O error.

Note: Moving the project to a local disk avoids the issue. But this is not acceptable in my shared environment, as my development server is where I make code edits.

### Expected behavior
Crush should either:
- Work reliably when the working directory is on SMB; or
- Detect that the DB is on a network filesystem and automatically choose safe SQLite settings; or
- Provide configuration to store Crush’s SQLite DB/state on a local path (e.g., $HOME/.config/crush) even when the project is on SMB, and/or allow overriding SQLite PRAGMAs (journal mode, synchronous, temp store).

### Screenshots
N/A, here is the CLI output: `Failed to apply migrations: sqlite3: disk I/O error; sqlite3: disk I/O error.`

### Additional context
- Root cause appears to be SQLite’s interaction with SMB locking/fsync semantics. Many SMB/CIFS mounts don’t fully support POSIX locking and durable handles by default, which can break SQLite WAL/journaling and manifests as disk I/O errors.
- Workarounds outside Crush include:
  - Running the project locally (works).
  - Tweaking SMB mount options (not always feasible for end users).
  - Forcing SQLite to non-WAL journaling (requires Crush to expose PRAGMAs).
- Proposed fixes/enhancements for Crush:
  1) Allow configuring the Crush DB/state directory separately from the project path (e.g., via crush.json or env var), so it can live on a local filesystem when projects are remote.
  2) Expose SQLite PRAGMA configuration in crush.json (e.g., journal_mode=DELETE or TRUNCATE; synchronous=FULL/NORMAL; temp_store=MEMORY).
  3) Auto-detect network filesystems (best-effort) and default to a safer SQLite mode when detected, or warn and suggest moving DB to a local path.
  4) Improve error messaging to indicate likely network-FS/locking cause and provide actionable guidance.

Implementing one or more of the above would make Crush more robust in environments where projects are on SMB shares.

Discussion:
@caarlos0: another workaround would be to add:

```json
{
  "options": {
   "data_directory": "/other/path/outside/smb"
  }
}
```

in your `crush.json`

Solution patch:
diff --git a/.gitattributes b/.gitattributes
index 6d364901..d0a96600 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,3 @@
 *.golden linguist-generated=true -text
 .github/crush-schema.json linguist-generated=true
+internal/agent/testdata/**/*.yaml -diff linguist-generated=true
diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json
index e71c5718..02b201f4 100644
--- a/.github/cla-signatures.json
+++ b/.github/cla-signatures.json
@@ -727,6 +727,102 @@
       "created_at": "2025-10-18T19:24:00Z",
       "repoId": 987670088,
       "pullRequestNo": 1254
+    },
+    {
+      "name": "Jesssullivan",
+      "id": 37297218,
+      "comment_id": 3439361465,
+      "created_at": "2025-10-23T21:50:17Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1292
+    },
+    {
+      "name": "mmangkad",
+      "id": 176301910,
+      "comment_id": 3440286180,
+      "created_at": "2025-10-24T01:32:47Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1294
+    },
+    {
+      "name": "blouflab",
+      "id": 227565774,
+      "comment_id": 3444483981,
+      "created_at": "2025-10-24T18:59:01Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1296
+    },
+    {
+      "name": "Supratim69",
+      "id": 128067781,
+      "comment_id": 3444891894,
+      "created_at": "2025-10-24T20:58:01Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1299
+    },
+    {
+      "name": "plandem",
+      "id": 1381366,
+      "comment_id": 3446669312,
+      "created_at": "2025-10-25T12:52:04Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1302
+    },
+    {
+      "name": "dawndiy",
+      "id": 1512740,
+      "comment_id": 3456210693,
+      "created_at": "2025-10-28T12:24:21Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1312
+    },
+    {
+      "name": "bradflaugher",
+      "id": 16511019,
+      "comment_id": 3458922489,
+      "created_at": "2025-10-28T23:13:15Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1324
+    },
+    {
+      "name": "teras",
+      "id": 592568,
+      "comment_id": 3465825058,
+      "created_at": "2025-10-30T02:04:26Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1335
+    },
+    {
+      "name": "heynemann",
+      "id": 60965,
+      "comment_id": 3475594747,
+      "created_at": "2025-11-01T03:21:03Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1357
+    },
+    {
+      "name": "niklasschaeffer",
+      "id": 1948226,
+      "comment_id": 3476119118,
+      "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
     }
   ]
 }
\ No newline at end of file
diff --git a/.github/workflows/schema-update.yml b/.github/workflows/schema-update.yml
index 466c3a25..ecfaf140 100644
--- a/.github/workflows/schema-update.yml
+++ b/.github/workflows/schema-update.yml
@@ -21,6 +21,6 @@ jobs:
         with:
           commit_message: "chore: auto-update generated files"
           branch: main
-          commit_user_name: actions-user
-          commit_user_email: actions@github.com
-          commit_author: actions-user <actions@github.com>
+          commit_user_name: Charm
+          commit_user_email: 124303983+charmcli@users.noreply.github.com
+          commit_author: Charm <124303983+charmcli@users.noreply.github.com>
diff --git a/.gitignore b/.gitignore
index 1bd8e7f9..01510713 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,7 +45,9 @@ Thumbs.db
 **/.crush/**
 
 /crush
+/tmp/
 
 manpages/
 completions/
 !internal/tui/components/completions/
+.prettierignore
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 28539bc1..1bfc9202 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -98,6 +98,7 @@ checksum:
 
 aur_sources:
   - private_key: "{{ .Env.AUR_KEY }}"
+    disable: "{{ with .Prerelease }}true{{ end }}"
     git_url: "ssh://aur@aur.archlinux.org/crush.git"
     commit_author:
       name: "Charm"
@@ -141,6 +142,7 @@ aur_sources:
 
 aurs:
   - private_key: "{{ .Env.AUR_KEY }}"
+    disable: "{{ with .Prerelease }}true{{ end }}"
     git_url: "ssh://aur@aur.archlinux.org/crush-bin.git"
     commit_author:
       name: "Charm"
@@ -170,7 +172,7 @@ aurs:
       install -Dm644 README* "${pkgdir}/usr/share/doc/crush/"
 
 furies:
-  - disable: "{{ .IsNightly }}"
+  - disable: "{{ if (or .Prerelease .IsNightly) }}true{{ end }}"
     account: "{{ with .Env.FURY_TOKEN }}charmcli{{ else }}{{ end }}"
     secret_name: FURY_TOKEN
 
@@ -179,6 +181,7 @@ brews:
       owner: charmbracelet
       name: homebrew-tap
       token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
+    skip_upload: "{{ with .Prerelease }}true{{ end }}"
     commit_author:
       name: "Charm"
       email: "charmcli@users.noreply.github.com"
@@ -194,6 +197,7 @@ scoops:
       owner: charmbracelet
       name: scoop-bucket
       token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
+    skip_upload: "{{ with .Prerelease }}true{{ end }}"
     commit_author:
       name: "Charm"
       email: "charmcli@users.noreply.github.com"
@@ -203,6 +207,7 @@ npms:
     repository: "git+https://github.com/charmbracelet/crush.git"
     bugs: https://github.com/charmbracelet/crush/issues
     access: public
+    disable: "{{ with .Prerelease }}true{{ end }}"
 
 nfpms:
   - formats:
@@ -257,6 +262,7 @@ nix:
       name: "Charm"
       email: "charmcli@users.noreply.github.com"
     license: fsl11Mit
+    skip_upload: "{{ with .Prerelease }}true{{ end }}"
     extra_install: |-
       installManPage ./manpages/crush.1.gz
       installShellCompletion ./completions/*
@@ -267,6 +273,7 @@ winget:
     publisher_url: https://charm.land
     release_notes_url: "https://github.com/charmbracelet/crush/releases/tag/{{.Tag}}"
     license_url: https://github.com/charmbracelet/crush/blob/main/LICENSE.md
+    skip_upload: "{{ with .Prerelease }}true{{ end }}"
     commit_author:
       name: "Charm"
       email: "charmcli@users.noreply.github.com"
diff --git a/CRUSH.md b/CRUSH.md
index dee2e7ba..b98b8813 100644
--- a/CRUSH.md
+++ b/CRUSH.md
@@ -6,7 +6,7 @@
 - **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
 - **Update Golden Files**: `go test ./... -update` (regenerates .golden files when test output changes)
   - Update specific package: `go test ./internal/tui/components/core -update` (in this case, we're updating "core")
-- **Lint**: `task lint-fix`
+- **Lint**: `task lint:fix`
 - **Format**: `task fmt` (gofumpt -w .)
 - **Dev**: `task dev` (runs with profiling enabled)
 
@@ -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.
diff --git a/README.md b/README.md
index 6ed20ff1..a3451eb4 100644
--- a/README.md
+++ b/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": {
@@ -367,7 +367,7 @@ Local models can also be configured via OpenAI-compatible API. Here are two comm
     "ollama": {
       "name": "Ollama",
       "base_url": "http://localhost:11434/v1/",
-      "type": "openai",
+      "type": "openai-compat",
       "models": [
         {
           "name": "Qwen 3 30B",
@@ -389,7 +389,7 @@ Local models can also be configured via OpenAI-compatible API. Here are two comm
     "lmstudio": {
       "name": "LM Studio",
       "base_url": "http://localhost:1234/v1/",
-      "type": "openai",
+      "type": "openai-compat",
       "models": [
         {
           "name": "Qwen 3 30B",
@@ -408,6 +408,12 @@ Local models can also be configured via OpenAI-compatible API. Here are two comm
 Crush supports custom provider configurations for both OpenAI-compatible and
 Anthropic-compatible APIs.
 
+> [!NOTE]
+> Note that we support two "types" for OpenAI. Make sure to choose the right one
+> to ensure the best experience!
+> * `openai` should be used when proxying or routing requests through OpenAI.
+> * `openai-compat` should be used when using non-OpenAI providers that have OpenAI-compatible APIs.
+
 #### OpenAI-Compatible APIs
 
 Here’s an example configuration for Deepseek, which uses an OpenAI-compatible
@@ -418,7 +424,7 @@ API. Don't forget to set `DEEPSEEK_API_KEY` in your environment.
   "$schema": "https://charm.land/crush.json",
   "providers": {
     "deepseek": {
-      "type": "openai",
+      "type": "openai-compat",
       "base_url": "https://api.deepseek.com/v1",
       "api_key": "$DEEPSEEK_API_KEY",
       "models": [
@@ -650,8 +656,8 @@ See the [contributing guide](https://github.com/charmbracelet/crush?tab=contribu
 We’d love to hear your thoughts on this project. Need help? We gotchu. You can find us on:
 
 - [Twitter](https://twitter.com/charmcli)
-- [Discord][discord]
 - [Slack](https://charm.land/slack)
+- [Discord][discord]
 - [The Fediverse](https://mastodon.social/@charmcli)
 - [Bluesky](https://bsky.app/profile/charm.land)
 
diff --git a/Taskfile.yaml b/Taskfile.yaml
index 65a7e2d4..a23d2ef4 100644
--- a/Taskfile.yaml
+++ b/Taskfile.yaml
@@ -2,6 +2,10 @@
 
 version: "3"
 
+vars:
+  VERSION:
+    sh: git describe --long 2>/dev/null || echo ""
+
 env:
   CGO_ENABLED: 0
   GOEXPERIMENT: greenteagc
@@ -21,7 +25,7 @@ tasks:
     env:
       GOEXPERIMENT: null
 
-  lint-fix:
+  lint:fix:
     desc: Run base linters and fix issues
     cmds:
       - golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m --fix
@@ -30,8 +34,10 @@ tasks:
 
   build:
     desc: Run build
+    vars:
+      LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
     cmds:
-      - go build .
+      - go build {{.LDFLAGS}} .
     generates:
       - crush
 
@@ -59,8 +65,10 @@ tasks:
 
   install:
     desc: Install the application
+    vars:
+      LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
     cmds:
-      - go install -v .
+      - go install {{.LDFLAGS}} -v .
 
   profile:cpu:
     desc: 10s CPU profile
diff --git a/go.mod b/go.mod
index 7c1cf2b1..6aa3f8d1 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,10 @@ module github.com/charmbracelet/crush
 go 1.25.0
 
 require (
+	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
@@ -11,30 +15,35 @@ 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/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf
-	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2
-	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619
-	github.com/charmbracelet/catwalk v0.7.0
+	github.com/charmbracelet/catwalk v0.8.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/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
+	github.com/charmbracelet/x/term v0.2.2
+	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
 	github.com/google/uuid v1.6.0
 	github.com/invopop/jsonschema v0.13.0
 	github.com/joho/godotenv v1.5.1
-	github.com/modelcontextprotocol/go-sdk v1.0.0
+	github.com/lucasb-eyer/go-colorful v1.3.0
+	github.com/modelcontextprotocol/go-sdk v1.1.0
 	github.com/muesli/termenv v0.16.0
 	github.com/ncruces/go-sqlite3 v0.29.1
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
 	github.com/nxadm/tail v1.4.11
-	github.com/openai/openai-go v1.12.0
+	github.com/openai/openai-go/v2 v2.7.1
+	github.com/posthog/posthog-go v1.6.12
 	github.com/pressly/goose/v3 v3.26.0
 	github.com/qjebbs/go-jsons v1.0.0-alpha.4
+	github.com/rivo/uniseg v0.4.7
 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
 	github.com/sahilm/fuzzy v0.1.1
 	github.com/spf13/cobra v1.10.1
@@ -43,19 +52,24 @@ require (
 	github.com/stretchr/testify v1.11.1
 	github.com/tidwall/sjson v1.2.5
 	github.com/zeebo/xxh3 v1.0.2
+	go.yaml.in/yaml/v4 v4.0.0-rc.2
+	golang.org/x/sync v0.17.0
+	golang.org/x/text v0.30.0
+	gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
+	mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
 	mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
 )
 
 require (
 	cloud.google.com/go v0.116.0 // indirect
-	cloud.google.com/go/auth v0.13.0 // indirect
-	cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
-	cloud.google.com/go/compute/metadata v0.6.0 // indirect
+	cloud.google.com/go/auth v0.17.0 // indirect
+	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
+	cloud.google.com/go/compute/metadata v0.8.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
-	github.com/aws/aws-sdk-go-v2 v1.30.3 // 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
@@ -68,21 +82,21 @@ require (
 	github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
 	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
 	github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
-	github.com/aws/smithy-go v1.20.3 // indirect
+	github.com/aws/smithy-go v1.23.1 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/bahlo/generic-list-go v0.2.0 // indirect
 	github.com/buger/jsonparser v1.1.1 // indirect
-	github.com/charmbracelet/colorprofile v0.3.2
-	github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef
+	github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // 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/exp/slice v0.0.0-20250829135019-44e44e21330d
-	github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
-	github.com/charmbracelet/x/term v0.2.1
+	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/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/gift v1.1.2 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
@@ -91,11 +105,12 @@ require (
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/google/jsonschema-go v0.3.0 // indirect
-	github.com/google/s2a-go v0.1.8 // indirect
-	github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
-	github.com/googleapis/gax-go/v2 v2.14.1 // indirect
+	github.com/google/s2a-go v0.1.9 // indirect
+	github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
+	github.com/googleapis/gax-go/v2 v2.15.0 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
 	github.com/gorilla/websocket v1.5.3 // indirect
 	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
@@ -103,10 +118,9 @@ require (
 	github.com/klauspost/compress v1.18.0 // indirect
 	github.com/klauspost/cpuid/v2 v2.0.9 // indirect
 	github.com/klauspost/pgzip v1.2.6 // indirect
-	github.com/lucasb-eyer/go-colorful v1.3.0
 	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
@@ -118,8 +132,6 @@ require (
 	github.com/ncruces/julianday v1.0.0 // indirect
 	github.com/pierrec/lz4/v4 v4.1.22 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/posthog/posthog-go v1.6.11
-	github.com/rivo/uniseg v0.4.7
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 	github.com/sethvargo/go-retry v0.3.0 // indirect
 	github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
@@ -136,28 +148,25 @@ require (
 	github.com/yuin/goldmark v1.7.8 // indirect
 	github.com/yuin/goldmark-emoji v1.0.5 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
 	go.opentelemetry.io/otel v1.37.0 // indirect
 	go.opentelemetry.io/otel/metric v1.37.0 // indirect
 	go.opentelemetry.io/otel/trace v1.37.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	golang.org/x/crypto v0.42.0 // indirect
 	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
-	golang.org/x/image v0.26.0 // indirect
-	golang.org/x/net v0.43.0 // indirect
-	golang.org/x/oauth2 v0.30.0 // indirect
-	golang.org/x/sync v0.17.0 // indirect
-	golang.org/x/sys v0.36.0 // indirect
+	golang.org/x/image v0.27.0 // indirect
+	golang.org/x/net v0.44.0 // indirect
+	golang.org/x/oauth2 v0.32.0 // indirect
+	golang.org/x/sys v0.37.0 // indirect
 	golang.org/x/term v0.35.0 // indirect
-	golang.org/x/text v0.30.0
-	golang.org/x/time v0.8.0 // indirect
-	google.golang.org/api v0.211.0 // indirect
-	google.golang.org/genai v1.31.0
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
-	google.golang.org/grpc v1.71.0 // indirect
-	google.golang.org/protobuf v1.36.8 // 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
 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
-	mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
 )
diff --git a/go.sum b/go.sum
index bbb776c2..412b6792 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,19 @@
+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.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
-cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
-cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
-cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
-cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
-cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
+cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
+cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
+cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
+cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
+cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
+cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
@@ -32,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.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
-github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
+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=
@@ -58,8 +66,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrA
 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
 github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
 github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
-github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
-github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
+github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
+github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
@@ -74,46 +82,50 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU
 github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
 github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg=
 github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
-github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf h1:toCE1GpniOr8JPJII2GH1AffivFVOzq8Rs2S0FUrkNU=
-github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf/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.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138=
-github.com/charmbracelet/catwalk v0.7.0 h1:qhLv56aeel5Q+2G/YFh9k5FhTqsozsn4HYViuAQ/Rio=
-github.com/charmbracelet/catwalk v0.7.0/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/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/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.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/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-20250915111650-81d4262876ef h1:VrWaUi2LXYLjfjCHowdSOEc6dQ9Ro14KY7Bw4IWd19M=
-github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef/go.mod h1:AThRsQH1t+dfyOKIwXRoJBniYFQUkUpQq4paheHMc2o=
-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-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA=
-github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
+github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
+github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
 github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
 github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
-github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
-github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
 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=
@@ -144,6 +156,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
 github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
 github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -153,14 +167,14 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
 github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
-github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
-github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
+github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
-github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
-github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
-github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
+github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
+github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
+github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
+github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
 github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -198,16 +212,16 @@ 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=
 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
-github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
+github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
+github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
@@ -230,8 +244,8 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6
 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
 github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
 github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
-github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
-github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
+github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
+github.com/openai/openai-go/v2 v2.7.1/go.mod h1:jrJs23apqJKKbT+pqtFgNKpRju/KP9zpUTZhz3GElQE=
 github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
 github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -239,15 +253,14 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posthog/posthog-go v1.6.11 h1:5G8Y3pxnOpc3S4+PK1z1dCmZRuldiWxBsqqvvSfC2+w=
-github.com/posthog/posthog-go v1.6.11/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
+github.com/posthog/posthog-go v1.6.12 h1:rsOBL/YdMfLJtOVjKJLgdzYmvaL3aIW6IVbAteSe+aI=
+github.com/posthog/posthog-go v1.6.12/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
 github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
 github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
 github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
 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=
@@ -315,22 +328,24 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
 github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
 go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
 go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
 go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
 go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
-go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
-go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
-go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
-go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
+go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
+go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
+go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
+go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
 go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
 go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
+go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -342,8 +357,8 @@ golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
 golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
-golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
-golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
+golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
+golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -360,10 +375,10 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
-golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
-golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
-golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
+golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
+golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
+golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
+golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -388,8 +403,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
-golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -414,8 +429,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
 golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
-golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
-golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -425,20 +440,22 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
 golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
 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.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg=
-google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0=
-google.golang.org/genai v1.31.0 h1:R7xDt/Dosz11vcXbZ4IgisGnzUGGau2PZOIOAnXsYjw=
-google.golang.org/genai v1.31.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
-google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
-google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
-google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
-google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+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=
+google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117 h1:fbE/sTnBb9UNfE8cJsOzrYYPqVWVHb7jWH4SI1W//cM=
+gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117/go.mod h1:YuVT9NPq7t3oT2WpUemB0DbNL7djIjgajZycxoDLnqs=
 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
 gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
diff --git a/internal/agent/.env.sample b/internal/agent/.env.sample
new file mode 100644
index 00000000..59121c5b
--- /dev/null
+++ b/internal/agent/.env.sample
@@ -0,0 +1,4 @@
+CRUSH_ANTHROPIC_API_KEY=
+CRUSH_OPENAI_API_KEY=
+CRUSH_OPENROUTER_API_KEY=
+CRUSH_ZAI_API_KEY=
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
new file mode 100644
index 00000000..deba2d19
--- /dev/null
+++ b/internal/agent/agent.go
@@ -0,0 +1,875 @@
+// 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 (
+	"context"
+	_ "embed"
+	"errors"
+	"fmt"
+	"log/slog"
+	"os"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"charm.land/fantasy"
+	"charm.land/fantasy/providers/anthropic"
+	"charm.land/fantasy/providers/bedrock"
+	"charm.land/fantasy/providers/google"
+	"charm.land/fantasy/providers/openai"
+	"charm.land/fantasy/providers/openrouter"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/session"
+)
+
+//go:embed templates/title.md
+var titlePrompt []byte
+
+//go:embed templates/summary.md
+var summaryPrompt []byte
+
+type SessionAgentCall struct {
+	SessionID        string
+	Prompt           string
+	ProviderOptions  fantasy.ProviderOptions
+	Attachments      []message.Attachment
+	MaxOutputTokens  int64
+	Temperature      *float64
+	TopP             *float64
+	TopK             *int64
+	FrequencyPenalty *float64
+	PresencePenalty  *float64
+}
+
+type SessionAgent interface {
+	Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)
+	SetModels(large Model, small Model)
+	SetTools(tools []fantasy.AgentTool)
+	Cancel(sessionID string)
+	CancelAll()
+	IsSessionBusy(sessionID string) bool
+	IsBusy() bool
+	QueuedPrompts(sessionID string) int
+	ClearQueue(sessionID string)
+	Summarize(context.Context, string, fantasy.ProviderOptions) error
+	Model() Model
+}
+
+type Model struct {
+	Model      fantasy.LanguageModel
+	CatwalkCfg catwalk.Model
+	ModelCfg   config.SelectedModel
+}
+
+type sessionAgent struct {
+	largeModel           Model
+	smallModel           Model
+	systemPromptPrefix   string
+	systemPrompt         string
+	tools                []fantasy.AgentTool
+	sessions             session.Service
+	messages             message.Service
+	disableAutoSummarize bool
+	isYolo               bool
+
+	messageQueue   *csync.Map[string, []SessionAgentCall]
+	activeRequests *csync.Map[string, context.CancelFunc]
+}
+
+type SessionAgentOptions struct {
+	LargeModel           Model
+	SmallModel           Model
+	SystemPromptPrefix   string
+	SystemPrompt         string
+	DisableAutoSummarize bool
+	IsYolo               bool
+	Sessions             session.Service
+	Messages             message.Service
+	Tools                []fantasy.AgentTool
+}
+
+func NewSessionAgent(
+	opts SessionAgentOptions,
+) SessionAgent {
+	return &sessionAgent{
+		largeModel:           opts.LargeModel,
+		smallModel:           opts.SmallModel,
+		systemPromptPrefix:   opts.SystemPromptPrefix,
+		systemPrompt:         opts.SystemPrompt,
+		sessions:             opts.Sessions,
+		messages:             opts.Messages,
+		disableAutoSummarize: opts.DisableAutoSummarize,
+		tools:                opts.Tools,
+		isYolo:               opts.IsYolo,
+		messageQueue:         csync.NewMap[string, []SessionAgentCall](),
+		activeRequests:       csync.NewMap[string, context.CancelFunc](),
+	}
+}
+
+func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
+	if call.Prompt == "" {
+		return nil, ErrEmptyPrompt
+	}
+	if call.SessionID == "" {
+		return nil, ErrSessionMissing
+	}
+
+	// Queue the message if busy
+	if a.IsSessionBusy(call.SessionID) {
+		existing, ok := a.messageQueue.Get(call.SessionID)
+		if !ok {
+			existing = []SessionAgentCall{}
+		}
+		existing = append(existing, call)
+		a.messageQueue.Set(call.SessionID, existing)
+		return nil, nil
+	}
+
+	if len(a.tools) > 0 {
+		// Add Anthropic caching to the last tool.
+		a.tools[len(a.tools)-1].SetProviderOptions(a.getCacheControlOptions())
+	}
+
+	agent := fantasy.NewAgent(
+		a.largeModel.Model,
+		fantasy.WithSystemPrompt(a.systemPrompt),
+		fantasy.WithTools(a.tools...),
+	)
+
+	sessionLock := sync.Mutex{}
+	currentSession, err := a.sessions.Get(ctx, call.SessionID)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get session: %w", err)
+	}
+
+	msgs, err := a.getSessionMessages(ctx, currentSession)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get session messages: %w", err)
+	}
+
+	var wg sync.WaitGroup
+	// Generate title if first message.
+	if len(msgs) == 0 {
+		wg.Go(func() {
+			sessionLock.Lock()
+			a.generateTitle(ctx, &currentSession, call.Prompt)
+			sessionLock.Unlock()
+		})
+	}
+
+	// Add the user message to the session.
+	_, err = a.createUserMessage(ctx, call)
+	if err != nil {
+		return nil, err
+	}
+
+	// Add the session to the context.
+	ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
+
+	genCtx, cancel := context.WithCancel(ctx)
+	a.activeRequests.Set(call.SessionID, cancel)
+
+	defer cancel()
+	defer a.activeRequests.Del(call.SessionID)
+
+	history, files := a.preparePrompt(msgs, call.Attachments...)
+
+	startTime := time.Now()
+	a.eventPromptSent(call.SessionID)
+
+	var currentAssistant *message.Message
+	var shouldSummarize bool
+	result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
+		Prompt:           call.Prompt,
+		Files:            files,
+		Messages:         history,
+		ProviderOptions:  call.ProviderOptions,
+		MaxOutputTokens:  &call.MaxOutputTokens,
+		TopP:             call.TopP,
+		Temperature:      call.Temperature,
+		PresencePenalty:  call.PresencePenalty,
+		TopK:             call.TopK,
+		FrequencyPenalty: call.FrequencyPenalty,
+		// 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.
+			for i := range prepared.Messages {
+				prepared.Messages[i].ProviderOptions = nil
+			}
+
+			queuedCalls, _ := a.messageQueue.Get(call.SessionID)
+			a.messageQueue.Del(call.SessionID)
+			for _, queued := range queuedCalls {
+				userMessage, createErr := a.createUserMessage(callContext, queued)
+				if createErr != nil {
+					return callContext, prepared, createErr
+				}
+				prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
+			}
+
+			lastSystemRoleInx := 0
+			systemMessageUpdated := false
+			for i, msg := range prepared.Messages {
+				// 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.
+				if i > len(prepared.Messages)-3 {
+					prepared.Messages[i].ProviderOptions = a.getCacheControlOptions()
+				}
+			}
+
+			if a.systemPromptPrefix != "" {
+				prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
+			}
+
+			var assistantMsg message.Message
+			assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
+				Role:     message.Assistant,
+				Parts:    []message.ContentPart{},
+				Model:    a.largeModel.ModelCfg.Model,
+				Provider: a.largeModel.ModelCfg.Provider,
+			})
+			if err != nil {
+				return callContext, prepared, err
+			}
+			callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
+			currentAssistant = &assistantMsg
+			return callContext, prepared, err
+		},
+		OnReasoningStart: func(id string, reasoning fantasy.ReasoningContent) error {
+			currentAssistant.AppendReasoningContent(reasoning.Text)
+			return a.messages.Update(genCtx, *currentAssistant)
+		},
+		OnReasoningDelta: func(id string, text string) error {
+			currentAssistant.AppendReasoningContent(text)
+			return a.messages.Update(genCtx, *currentAssistant)
+		},
+		OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
+			// handle anthropic signature
+			if anthropicData, ok := reasoning.ProviderMetadata[anthropic.Name]; ok {
+				if reasoning, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok {
+					currentAssistant.AppendReasoningSignature(reasoning.Signature)
+				}
+			}
+			if googleData, ok := reasoning.ProviderMetadata[google.Name]; ok {
+				if reasoning, ok := googleData.(*google.ReasoningMetadata); ok {
+					currentAssistant.AppendReasoningSignature(reasoning.Signature)
+				}
+			}
+			if openaiData, ok := reasoning.ProviderMetadata[openai.Name]; ok {
+				if reasoning, ok := openaiData.(*openai.ResponsesReasoningMetadata); ok {
+					currentAssistant.SetReasoningResponsesData(reasoning)
+				}
+			}
+			currentAssistant.FinishThinking()
+			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)
+		},
+		OnToolInputStart: func(id string, toolName string) error {
+			toolCall := message.ToolCall{
+				ID:               id,
+				Name:             toolName,
+				ProviderExecuted: false,
+				Finished:         false,
+			}
+			currentAssistant.AddToolCall(toolCall)
+			return a.messages.Update(genCtx, *currentAssistant)
+		},
+		OnRetry: func(err *fantasy.APICallError, delay time.Duration) {
+			// TODO: implement
+		},
+		OnToolCall: func(tc fantasy.ToolCallContent) error {
+			toolCall := message.ToolCall{
+				ID:               tc.ToolCallID,
+				Name:             tc.ToolName,
+				Input:            tc.Input,
+				ProviderExecuted: false,
+				Finished:         true,
+			}
+			currentAssistant.AddToolCall(toolCall)
+			return a.messages.Update(genCtx, *currentAssistant)
+		},
+		OnToolResult: func(result fantasy.ToolResultContent) error {
+			var resultContent string
+			isError := false
+			switch result.Result.GetType() {
+			case fantasy.ToolResultContentTypeText:
+				r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result.Result)
+				if ok {
+					resultContent = r.Text
+				}
+			case fantasy.ToolResultContentTypeError:
+				r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result.Result)
+				if ok {
+					isError = true
+					resultContent = r.Error.Error()
+				}
+			case fantasy.ToolResultContentTypeMedia:
+				// TODO: handle this message type
+			}
+			toolResult := message.ToolResult{
+				ToolCallID: result.ToolCallID,
+				Name:       result.ToolName,
+				Content:    resultContent,
+				IsError:    isError,
+				Metadata:   result.ClientMetadata,
+			}
+			_, createMsgErr := a.messages.Create(genCtx, currentAssistant.SessionID, message.CreateMessageParams{
+				Role: message.Tool,
+				Parts: []message.ContentPart{
+					toolResult,
+				},
+			})
+			if createMsgErr != nil {
+				return createMsgErr
+			}
+			return nil
+		},
+		OnStepFinish: func(stepResult fantasy.StepResult) error {
+			finishReason := message.FinishReasonUnknown
+			switch stepResult.FinishReason {
+			case fantasy.FinishReasonLength:
+				finishReason = message.FinishReasonMaxTokens
+			case fantasy.FinishReasonStop:
+				finishReason = message.FinishReasonEndTurn
+			case fantasy.FinishReasonToolCalls:
+				finishReason = message.FinishReasonToolUse
+			}
+			currentAssistant.AddFinish(finishReason, "", "")
+			a.updateSessionUsage(a.largeModel, &currentSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
+			sessionLock.Lock()
+			_, sessionErr := a.sessions.Save(genCtx, currentSession)
+			sessionLock.Unlock()
+			if sessionErr != nil {
+				return sessionErr
+			}
+			return a.messages.Update(genCtx, *currentAssistant)
+		},
+		StopWhen: []fantasy.StopCondition{
+			func(_ []fantasy.StepResult) bool {
+				cw := int64(a.largeModel.CatwalkCfg.ContextWindow)
+				tokens := currentSession.CompletionTokens + currentSession.PromptTokens
+				remaining := cw - tokens
+				var threshold int64
+				if cw > 200_000 {
+					threshold = 20_000
+				} else {
+					threshold = int64(float64(cw) * 0.2)
+				}
+				if (remaining <= threshold) && !a.disableAutoSummarize {
+					shouldSummarize = true
+					return true
+				}
+				return false
+			},
+		},
+	})
+
+	a.eventPromptResponded(call.SessionID, time.Since(startTime).Truncate(time.Second))
+
+	if err != nil {
+		isCancelErr := errors.Is(err, context.Canceled)
+		isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
+		if currentAssistant == nil {
+			return result, err
+		}
+		// 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.
+		msgs, createErr := a.messages.List(ctx, currentAssistant.SessionID)
+		if createErr != nil {
+			return nil, createErr
+		}
+		for _, tc := range toolCalls {
+			if !tc.Finished {
+				tc.Finished = true
+				tc.Input = "{}"
+				currentAssistant.AddToolCall(tc)
+				updateErr := a.messages.Update(ctx, *currentAssistant)
+				if updateErr != nil {
+					return nil, updateErr
+				}
+			}
+
+			found := false
+			for _, msg := range msgs {
+				if msg.Role == message.Tool {
+					for _, tr := range msg.ToolResults() {
+						if tr.ToolCallID == tc.ID {
+							found = true
+							break
+						}
+					}
+				}
+				if found {
+					break
+				}
+			}
+			if found {
+				continue
+			}
+			content := "There was an error while executing the tool"
+			if isCancelErr {
+				content = "Tool execution canceled by user"
+			} else if isPermissionErr {
+				content = "User denied permission"
+			}
+			toolResult := message.ToolResult{
+				ToolCallID: tc.ID,
+				Name:       tc.Name,
+				Content:    content,
+				IsError:    true,
+			}
+			_, createErr = a.messages.Create(context.Background(), currentAssistant.SessionID, message.CreateMessageParams{
+				Role: message.Tool,
+				Parts: []message.ContentPart{
+					toolResult,
+				},
+			})
+			if createErr != nil {
+				return nil, createErr
+			}
+		}
+		if isCancelErr {
+			currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
+		} else if isPermissionErr {
+			currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
+		} else {
+			currentAssistant.AddFinish(message.FinishReasonError, "API Error", err.Error())
+		}
+		// 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
+		}
+		return nil, err
+	}
+	wg.Wait()
+
+	if shouldSummarize {
+		a.activeRequests.Del(call.SessionID)
+		if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
+			return nil, summarizeErr
+		}
+		// If the agent wasn't done...
+		if len(currentAssistant.ToolCalls()) > 0 {
+			existing, ok := a.messageQueue.Get(call.SessionID)
+			if !ok {
+				existing = []SessionAgentCall{}
+			}
+			call.Prompt = fmt.Sprintf("The previous session was interrupted because it got too long, the initial user request was: `%s`", call.Prompt)
+			existing = append(existing, call)
+			a.messageQueue.Set(call.SessionID, existing)
+		}
+	}
+
+	// Release active request before processing queued messages.
+	a.activeRequests.Del(call.SessionID)
+	cancel()
+
+	queuedMessages, ok := a.messageQueue.Get(call.SessionID)
+	if !ok || len(queuedMessages) == 0 {
+		return result, err
+	}
+	// There are queued messages restart the loop.
+	firstQueuedMessage := queuedMessages[0]
+	a.messageQueue.Set(call.SessionID, queuedMessages[1:])
+	return a.Run(ctx, firstQueuedMessage)
+}
+
+func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fantasy.ProviderOptions) error {
+	if a.IsSessionBusy(sessionID) {
+		return ErrSessionBusy
+	}
+
+	currentSession, err := a.sessions.Get(ctx, sessionID)
+	if err != nil {
+		return fmt.Errorf("failed to get session: %w", err)
+	}
+	msgs, err := a.getSessionMessages(ctx, currentSession)
+	if err != nil {
+		return err
+	}
+	if len(msgs) == 0 {
+		// Nothing to summarize.
+		return nil
+	}
+
+	aiMsgs, _ := a.preparePrompt(msgs)
+
+	genCtx, cancel := context.WithCancel(ctx)
+	a.activeRequests.Set(sessionID, cancel)
+	defer a.activeRequests.Del(sessionID)
+	defer cancel()
+
+	agent := fantasy.NewAgent(a.largeModel.Model,
+		fantasy.WithSystemPrompt(string(summaryPrompt)),
+	)
+	summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
+		Role:             message.Assistant,
+		Model:            a.largeModel.Model.Model(),
+		Provider:         a.largeModel.Model.Provider(),
+		IsSummaryMessage: true,
+	})
+	if err != nil {
+		return err
+	}
+
+	resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
+		Prompt:          "Provide a detailed summary of our conversation above.",
+		Messages:        aiMsgs,
+		ProviderOptions: opts,
+		PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
+			prepared.Messages = options.Messages
+			if a.systemPromptPrefix != "" {
+				prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
+			}
+			return callContext, prepared, nil
+		},
+		OnReasoningDelta: func(id string, text string) error {
+			summaryMessage.AppendReasoningContent(text)
+			return a.messages.Update(genCtx, summaryMessage)
+		},
+		OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
+			// Handle anthropic signature.
+			if anthropicData, ok := reasoning.ProviderMetadata["anthropic"]; ok {
+				if signature, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok && signature.Signature != "" {
+					summaryMessage.AppendReasoningSignature(signature.Signature)
+				}
+			}
+			summaryMessage.FinishThinking()
+			return a.messages.Update(genCtx, summaryMessage)
+		},
+		OnTextDelta: func(id, text string) error {
+			summaryMessage.AppendContent(text)
+			return a.messages.Update(genCtx, summaryMessage)
+		},
+	})
+	if err != nil {
+		isCancelErr := errors.Is(err, context.Canceled)
+		if isCancelErr {
+			// User cancelled summarize we need to remove the summary message.
+			deleteErr := a.messages.Delete(ctx, summaryMessage.ID)
+			return deleteErr
+		}
+		return err
+	}
+
+	summaryMessage.AddFinish(message.FinishReasonEndTurn, "", "")
+	err = a.messages.Update(genCtx, summaryMessage)
+	if err != nil {
+		return err
+	}
+
+	var openrouterCost *float64
+	for _, step := range resp.Steps {
+		stepCost := a.openrouterCost(step.ProviderMetadata)
+		if stepCost != nil {
+			newCost := *stepCost
+			if openrouterCost != nil {
+				newCost += *openrouterCost
+			}
+			openrouterCost = &newCost
+		}
+	}
+
+	a.updateSessionUsage(a.largeModel, &currentSession, resp.TotalUsage, openrouterCost)
+
+	// Just in case, get just the last usage info.
+	usage := resp.Response.Usage
+	currentSession.SummaryMessageID = summaryMessage.ID
+	currentSession.CompletionTokens = usage.OutputTokens
+	currentSession.PromptTokens = 0
+	_, err = a.sessions.Save(genCtx, currentSession)
+	return err
+}
+
+func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
+	if t, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_ANTHROPIC_CACHE")); t {
+		return fantasy.ProviderOptions{}
+	}
+	return fantasy.ProviderOptions{
+		anthropic.Name: &anthropic.ProviderCacheControlOptions{
+			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
+		},
+		bedrock.Name: &anthropic.ProviderCacheControlOptions{
+			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
+		},
+	}
+}
+
+func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
+	var attachmentParts []message.ContentPart
+	for _, attachment := range call.Attachments {
+		attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
+	}
+	parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
+	parts = append(parts, attachmentParts...)
+	msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
+		Role:  message.User,
+		Parts: parts,
+	})
+	if err != nil {
+		return message.Message{}, fmt.Errorf("failed to create user message: %w", err)
+	}
+	return msg, nil
+}
+
+func (a *sessionAgent) preparePrompt(msgs []message.Message, attachments ...message.Attachment) ([]fantasy.Message, []fantasy.FilePart) {
+	var history []fantasy.Message
+	for _, m := range msgs {
+		if len(m.Parts) == 0 {
+			continue
+		}
+		// 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
+		}
+		history = append(history, m.ToAIMessage()...)
+	}
+
+	var files []fantasy.FilePart
+	for _, attachment := range attachments {
+		files = append(files, fantasy.FilePart{
+			Filename:  attachment.FileName,
+			Data:      attachment.Content,
+			MediaType: attachment.MimeType,
+		})
+	}
+
+	return history, files
+}
+
+func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
+	msgs, err := a.messages.List(ctx, session.ID)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list messages: %w", err)
+	}
+
+	if session.SummaryMessageID != "" {
+		summaryMsgInex := -1
+		for i, msg := range msgs {
+			if msg.ID == session.SummaryMessageID {
+				summaryMsgInex = i
+				break
+			}
+		}
+		if summaryMsgInex != -1 {
+			msgs = msgs[summaryMsgInex:]
+			msgs[0].Role = message.User
+		}
+	}
+	return msgs, nil
+}
+
+func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Session, prompt string) {
+	if prompt == "" {
+		return
+	}
+
+	var maxOutput int64 = 40
+	if a.smallModel.CatwalkCfg.CanReason {
+		maxOutput = a.smallModel.CatwalkCfg.DefaultMaxTokens
+	}
+
+	agent := fantasy.NewAgent(a.smallModel.Model,
+		fantasy.WithSystemPrompt(string(titlePrompt)+"\n /no_think"),
+		fantasy.WithMaxOutputTokens(maxOutput),
+	)
+
+	resp, err := agent.Stream(ctx, fantasy.AgentStreamCall{
+		Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", prompt),
+		PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
+			prepared.Messages = options.Messages
+			if a.systemPromptPrefix != "" {
+				prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
+			}
+			return callContext, prepared, nil
+		},
+	})
+	if err != nil {
+		slog.Error("error generating title", "err", err)
+		return
+	}
+
+	title := resp.Response.Content.Text()
+
+	title = strings.ReplaceAll(title, "\n", " ")
+
+	// Remove thinking tags if present.
+	if idx := strings.Index(title, "</think>"); idx > 0 {
+		title = title[idx+len("</think>"):]
+	}
+
+	title = strings.TrimSpace(title)
+	if title == "" {
+		slog.Warn("failed to generate title", "warn", "empty title")
+		return
+	}
+
+	session.Title = title
+
+	var openrouterCost *float64
+	for _, step := range resp.Steps {
+		stepCost := a.openrouterCost(step.ProviderMetadata)
+		if stepCost != nil {
+			newCost := *stepCost
+			if openrouterCost != nil {
+				newCost += *openrouterCost
+			}
+			openrouterCost = &newCost
+		}
+	}
+
+	a.updateSessionUsage(a.smallModel, session, resp.TotalUsage, openrouterCost)
+	_, saveErr := a.sessions.Save(ctx, *session)
+	if saveErr != nil {
+		slog.Error("failed to save session title & usage", "error", saveErr)
+		return
+	}
+}
+
+func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float64 {
+	openrouterMetadata, ok := metadata[openrouter.Name]
+	if !ok {
+		return nil
+	}
+
+	opts, ok := openrouterMetadata.(*openrouter.ProviderMetadata)
+	if !ok {
+		return nil
+	}
+	return &opts.Usage.Cost
+}
+
+func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64) {
+	modelConfig := model.CatwalkCfg
+	cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
+		modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
+		modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
+		modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
+
+	a.eventTokensUsed(session.ID, model, usage, cost)
+
+	if overrideCost != nil {
+		session.Cost += *overrideCost
+	} else {
+		session.Cost += cost
+	}
+
+	session.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
+	session.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
+}
+
+func (a *sessionAgent) Cancel(sessionID string) {
+	// 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.
+	if cancel, ok := a.activeRequests.Take(sessionID + "-summarize"); ok && cancel != nil {
+		slog.Info("Summarize cancellation initiated", "session_id", sessionID)
+		cancel()
+	}
+
+	if a.QueuedPrompts(sessionID) > 0 {
+		slog.Info("Clearing queued prompts", "session_id", sessionID)
+		a.messageQueue.Del(sessionID)
+	}
+}
+
+func (a *sessionAgent) ClearQueue(sessionID string) {
+	if a.QueuedPrompts(sessionID) > 0 {
+		slog.Info("Clearing queued prompts", "session_id", sessionID)
+		a.messageQueue.Del(sessionID)
+	}
+}
+
+func (a *sessionAgent) CancelAll() {
+	if !a.IsBusy() {
+		return
+	}
+	for key := range a.activeRequests.Seq2() {
+		a.Cancel(key) // key is sessionID
+	}
+
+	timeout := time.After(5 * time.Second)
+	for a.IsBusy() {
+		select {
+		case <-timeout:
+			return
+		default:
+			time.Sleep(200 * time.Millisecond)
+		}
+	}
+}
+
+func (a *sessionAgent) IsBusy() bool {
+	var busy bool
+	for cancelFunc := range a.activeRequests.Seq() {
+		if cancelFunc != nil {
+			busy = true
+			break
+		}
+	}
+	return busy
+}
+
+func (a *sessionAgent) IsSessionBusy(sessionID string) bool {
+	_, busy := a.activeRequests.Get(sessionID)
+	return busy
+}
+
+func (a *sessionAgent) QueuedPrompts(sessionID string) int {
+	l, ok := a.messageQueue.Get(sessionID)
+	if !ok {
+		return 0
+	}
+	return len(l)
+}
+
+func (a *sessionAgent) SetModels(large Model, small Model) {
+	a.largeModel = large
+	a.smallModel = small
+}
+
+func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
+	a.tools = tools
+}
+
+func (a *sessionAgent) Model() Model {
+	return a.largeModel
+}
diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go
new file mode 100644
index 00000000..b937c20d
--- /dev/null
+++ b/internal/agent/agent_tool.go
@@ -0,0 +1,105 @@
+package agent
+
+import (
+	"context"
+	_ "embed"
+	"errors"
+	"fmt"
+
+	"charm.land/fantasy"
+
+	"github.com/charmbracelet/crush/internal/agent/prompt"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/config"
+)
+
+//go:embed templates/agent_tool.md
+var agentToolDescription []byte
+
+type AgentParams struct {
+	Prompt string `json:"prompt" description:"The task for the agent to perform"`
+}
+
+const (
+	AgentToolName = "agent"
+)
+
+func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) {
+	agentCfg, ok := c.cfg.Agents[config.AgentTask]
+	if !ok {
+		return nil, errors.New("task agent not configured")
+	}
+	prompt, err := taskPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
+	if err != nil {
+		return nil, err
+	}
+
+	agent, err := c.buildAgent(ctx, prompt, agentCfg)
+	if err != nil {
+		return nil, err
+	}
+	return fantasy.NewAgentTool(
+		AgentToolName,
+		string(agentToolDescription),
+		func(ctx context.Context, params AgentParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.Prompt == "" {
+				return fantasy.NewTextErrorResponse("prompt is required"), nil
+			}
+
+			sessionID := tools.GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, errors.New("session id missing from context")
+			}
+
+			agentMessageID := tools.GetMessageFromContext(ctx)
+			if agentMessageID == "" {
+				return fantasy.ToolResponse{}, errors.New("agent message id missing from context")
+			}
+
+			agentToolSessionID := c.sessions.CreateAgentToolSessionID(agentMessageID, call.ID)
+			session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, sessionID, "New Agent Session")
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
+			}
+			model := agent.Model()
+			maxTokens := model.CatwalkCfg.DefaultMaxTokens
+			if model.ModelCfg.MaxTokens != 0 {
+				maxTokens = model.ModelCfg.MaxTokens
+			}
+
+			providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
+			if !ok {
+				return fantasy.ToolResponse{}, errors.New("model provider not configured")
+			}
+			result, err := agent.Run(ctx, SessionAgentCall{
+				SessionID:        session.ID,
+				Prompt:           params.Prompt,
+				MaxOutputTokens:  maxTokens,
+				ProviderOptions:  getProviderOptions(model, providerCfg),
+				Temperature:      model.ModelCfg.Temperature,
+				TopP:             model.ModelCfg.TopP,
+				TopK:             model.ModelCfg.TopK,
+				FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
+				PresencePenalty:  model.ModelCfg.PresencePenalty,
+			})
+			if err != nil {
+				return fantasy.NewTextErrorResponse("error generating response"), nil
+			}
+			updatedSession, err := c.sessions.Get(ctx, session.ID)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
+			}
+			parentSession, err := c.sessions.Get(ctx, sessionID)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err)
+			}
+
+			parentSession.Cost += updatedSession.Cost
+
+			_, err = c.sessions.Save(ctx, parentSession)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
+			}
+			return fantasy.NewTextResponse(result.Response.Content.Text()), nil
+		}), nil
+}
diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go
new file mode 100644
index 00000000..ff99e677
--- /dev/null
+++ b/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
+}
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
new file mode 100644
index 00000000..8f1f03f4
--- /dev/null
+++ b/internal/agent/coordinator.go
@@ -0,0 +1,756 @@
+package agent
+
+import (
+	"bytes"
+	"cmp"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"maps"
+	"os"
+	"slices"
+	"strings"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/agent/prompt"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/log"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/session"
+	"golang.org/x/sync/errgroup"
+
+	"charm.land/fantasy/providers/anthropic"
+	"charm.land/fantasy/providers/azure"
+	"charm.land/fantasy/providers/bedrock"
+	"charm.land/fantasy/providers/google"
+	"charm.land/fantasy/providers/openai"
+	"charm.land/fantasy/providers/openaicompat"
+	"charm.land/fantasy/providers/openrouter"
+	openaisdk "github.com/openai/openai-go/v2/option"
+	"github.com/qjebbs/go-jsons"
+)
+
+type Coordinator interface {
+	// INFO: (kujtim) this is not used yet we will use this when we have multiple agents
+	// SetMainAgent(string)
+	Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
+	Cancel(sessionID string)
+	CancelAll()
+	IsSessionBusy(sessionID string) bool
+	IsBusy() bool
+	QueuedPrompts(sessionID string) int
+	ClearQueue(sessionID string)
+	Summarize(context.Context, string) error
+	Model() Model
+	UpdateModels(ctx context.Context) error
+}
+
+type coordinator struct {
+	cfg         *config.Config
+	sessions    session.Service
+	messages    message.Service
+	permissions permission.Service
+	history     history.Service
+	lspClients  *csync.Map[string, *lsp.Client]
+
+	currentAgent SessionAgent
+	agents       map[string]SessionAgent
+
+	readyWg errgroup.Group
+}
+
+func NewCoordinator(
+	ctx context.Context,
+	cfg *config.Config,
+	sessions session.Service,
+	messages message.Service,
+	permissions permission.Service,
+	history history.Service,
+	lspClients *csync.Map[string, *lsp.Client],
+) (Coordinator, error) {
+	c := &coordinator{
+		cfg:         cfg,
+		sessions:    sessions,
+		messages:    messages,
+		permissions: permissions,
+		history:     history,
+		lspClients:  lspClients,
+		agents:      make(map[string]SessionAgent),
+	}
+
+	agentCfg, ok := cfg.Agents[config.AgentCoder]
+	if !ok {
+		return nil, errors.New("coder agent not configured")
+	}
+
+	// TODO: make this dynamic when we support multiple agents
+	prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
+	if err != nil {
+		return nil, err
+	}
+
+	agent, err := c.buildAgent(ctx, prompt, agentCfg)
+	if err != nil {
+		return nil, err
+	}
+	c.currentAgent = agent
+	c.agents[config.AgentCoder] = agent
+	return c, nil
+}
+
+// Run implements Coordinator.
+func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
+	if err := c.readyWg.Wait(); err != nil {
+		return nil, err
+	}
+
+	model := c.currentAgent.Model()
+	maxTokens := model.CatwalkCfg.DefaultMaxTokens
+	if model.ModelCfg.MaxTokens != 0 {
+		maxTokens = model.ModelCfg.MaxTokens
+	}
+
+	if !model.CatwalkCfg.SupportsImages && attachments != nil {
+		attachments = nil
+	}
+
+	providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
+	if !ok {
+		return nil, errors.New("model provider not configured")
+	}
+
+	mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
+
+	return c.currentAgent.Run(ctx, SessionAgentCall{
+		SessionID:        sessionID,
+		Prompt:           prompt,
+		Attachments:      attachments,
+		MaxOutputTokens:  maxTokens,
+		ProviderOptions:  mergedOptions,
+		Temperature:      temp,
+		TopP:             topP,
+		TopK:             topK,
+		FrequencyPenalty: freqPenalty,
+		PresencePenalty:  presPenalty,
+	})
+}
+
+func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
+	options := fantasy.ProviderOptions{}
+
+	cfgOpts := []byte("{}")
+	providerCfgOpts := []byte("{}")
+	catwalkOpts := []byte("{}")
+
+	if model.ModelCfg.ProviderOptions != nil {
+		data, err := json.Marshal(model.ModelCfg.ProviderOptions)
+		if err == nil {
+			cfgOpts = data
+		}
+	}
+
+	if providerCfg.ProviderOptions != nil {
+		data, err := json.Marshal(providerCfg.ProviderOptions)
+		if err == nil {
+			providerCfgOpts = data
+		}
+	}
+
+	if model.CatwalkCfg.Options.ProviderOptions != nil {
+		data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
+		if err == nil {
+			catwalkOpts = data
+		}
+	}
+
+	readers := []io.Reader{
+		bytes.NewReader(catwalkOpts),
+		bytes.NewReader(providerCfgOpts),
+		bytes.NewReader(cfgOpts),
+	}
+
+	got, err := jsons.Merge(readers)
+	if err != nil {
+		slog.Error("Could not merge call config", "err", err)
+		return options
+	}
+
+	mergedOptions := make(map[string]any)
+
+	err = json.Unmarshal([]byte(got), &mergedOptions)
+	if err != nil {
+		slog.Error("Could not create config for call", "err", err)
+		return options
+	}
+
+	switch providerCfg.Type {
+	case openai.Name, azure.Name:
+		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
+		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
+			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
+		}
+		if openai.IsResponsesModel(model.CatwalkCfg.ID) {
+			if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
+				mergedOptions["reasoning_summary"] = "auto"
+				mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
+			}
+			parsed, err := openai.ParseResponsesOptions(mergedOptions)
+			if err == nil {
+				options[openai.Name] = parsed
+			}
+		} else {
+			parsed, err := openai.ParseOptions(mergedOptions)
+			if err == nil {
+				options[openai.Name] = parsed
+			}
+		}
+	case anthropic.Name:
+		_, hasThink := mergedOptions["thinking"]
+		if !hasThink && model.ModelCfg.Think {
+			mergedOptions["thinking"] = map[string]any{
+				// TODO: kujtim see if we need to make this dynamic
+				"budget_tokens": 2000,
+			}
+		}
+		parsed, err := anthropic.ParseOptions(mergedOptions)
+		if err == nil {
+			options[anthropic.Name] = parsed
+		}
+
+	case openrouter.Name:
+		_, hasReasoning := mergedOptions["reasoning"]
+		if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
+			mergedOptions["reasoning"] = map[string]any{
+				"enabled": true,
+				"effort":  model.ModelCfg.ReasoningEffort,
+			}
+		}
+		parsed, err := openrouter.ParseOptions(mergedOptions)
+		if err == nil {
+			options[openrouter.Name] = parsed
+		}
+	case google.Name:
+		_, hasReasoning := mergedOptions["thinking_config"]
+		if !hasReasoning {
+			mergedOptions["thinking_config"] = map[string]any{
+				"thinking_budget":  2000,
+				"include_thoughts": true,
+			}
+		}
+		parsed, err := google.ParseOptions(mergedOptions)
+		if err == nil {
+			options[google.Name] = parsed
+		}
+	case openaicompat.Name:
+		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
+		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
+			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
+		}
+		parsed, err := openaicompat.ParseOptions(mergedOptions)
+		if err == nil {
+			options[openaicompat.Name] = parsed
+		}
+	}
+
+	return options
+}
+
+func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
+	modelOptions := getProviderOptions(model, cfg)
+	temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
+	topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
+	topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
+	freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
+	presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
+	return modelOptions, temp, topP, topK, freqPenalty, presPenalty
+}
+
+func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent) (SessionAgent, error) {
+	large, small, err := c.buildAgentModels(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
+	if err != nil {
+		return nil, err
+	}
+
+	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
+	result := NewSessionAgent(SessionAgentOptions{
+		large,
+		small,
+		largeProviderCfg.SystemPromptPrefix,
+		systemPrompt,
+		c.cfg.Options.DisableAutoSummarize,
+		c.permissions.SkipRequests(),
+		c.sessions,
+		c.messages,
+		nil,
+	})
+	c.readyWg.Go(func() error {
+		tools, err := c.buildTools(ctx, agent)
+		if err != nil {
+			return err
+		}
+		result.SetTools(tools)
+		return nil
+	})
+
+	return result, nil
+}
+
+func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
+	var allTools []fantasy.AgentTool
+	if slices.Contains(agent.AllowedTools, AgentToolName) {
+		agentTool, err := c.agentTool(ctx)
+		if err != nil {
+			return nil, err
+		}
+		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),
+		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
+		tools.NewGlobTool(c.cfg.WorkingDir()),
+		tools.NewGrepTool(c.cfg.WorkingDir()),
+		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
+		tools.NewSourcegraphTool(nil),
+		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
+		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+	)
+
+	if len(c.cfg.LSP) > 0 {
+		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
+	}
+
+	var filteredTools []fantasy.AgentTool
+	for _, tool := range allTools {
+		if slices.Contains(agent.AllowedTools, tool.Info().Name) {
+			filteredTools = append(filteredTools, tool)
+		}
+	}
+
+	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
+		if agent.AllowedMCP == nil {
+			// No MCP restrictions
+			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 != tool.MCP() {
+				continue
+			}
+			if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
+				filteredTools = append(filteredTools, tool)
+			}
+		}
+	}
+	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
+		return strings.Compare(a.Info().Name, b.Info().Name)
+	})
+	return filteredTools, nil
+}
+
+// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
+func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
+	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
+	if !ok {
+		return Model{}, Model{}, errors.New("large model not selected")
+	}
+	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
+	if !ok {
+		return Model{}, Model{}, errors.New("small model not selected")
+	}
+
+	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
+	if !ok {
+		return Model{}, Model{}, errors.New("large model provider not configured")
+	}
+
+	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
+	if err != nil {
+		return Model{}, Model{}, err
+	}
+
+	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
+	if !ok {
+		return Model{}, Model{}, errors.New("large model provider not configured")
+	}
+
+	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
+	if err != nil {
+		return Model{}, Model{}, err
+	}
+
+	var largeCatwalkModel *catwalk.Model
+	var smallCatwalkModel *catwalk.Model
+
+	for _, m := range largeProviderCfg.Models {
+		if m.ID == largeModelCfg.Model {
+			largeCatwalkModel = &m
+		}
+	}
+	for _, m := range smallProviderCfg.Models {
+		if m.ID == smallModelCfg.Model {
+			smallCatwalkModel = &m
+		}
+	}
+
+	if largeCatwalkModel == nil {
+		return Model{}, Model{}, errors.New("large model not found in provider config")
+	}
+
+	if smallCatwalkModel == nil {
+		return Model{}, Model{}, errors.New("snall model not found in provider config")
+	}
+
+	largeModelID := largeModelCfg.Model
+	smallModelID := smallModelCfg.Model
+
+	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
+		largeModelID += ":exacto"
+	}
+
+	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
+		smallModelID += ":exacto"
+	}
+
+	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
+	if err != nil {
+		return Model{}, Model{}, err
+	}
+	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
+	if err != nil {
+		return Model{}, Model{}, err
+	}
+
+	return Model{
+			Model:      largeModel,
+			CatwalkCfg: *largeCatwalkModel,
+			ModelCfg:   largeModelCfg,
+		}, Model{
+			Model:      smallModel,
+			CatwalkCfg: *smallCatwalkModel,
+			ModelCfg:   smallModelCfg,
+		}, nil
+}
+
+func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
+	hasBearerAuth := false
+	for key := range headers {
+		if strings.ToLower(key) == "authorization" {
+			hasBearerAuth = true
+			break
+		}
+	}
+
+	isBearerToken := strings.HasPrefix(apiKey, "Bearer ")
+
+	var opts []anthropic.Option
+	if apiKey != "" && !hasBearerAuth {
+		if isBearerToken {
+			slog.Debug("API key starts with 'Bearer ', using as Authorization header")
+			headers["Authorization"] = apiKey
+			apiKey = "" // clear apiKey to avoid using X-Api-Key header
+		}
+	}
+
+	if apiKey != "" {
+		// Use standard X-Api-Key header
+		opts = append(opts, anthropic.WithAPIKey(apiKey))
+	}
+
+	if len(headers) > 0 {
+		opts = append(opts, anthropic.WithHeaders(headers))
+	}
+
+	if baseURL != "" {
+		opts = append(opts, anthropic.WithBaseURL(baseURL))
+	}
+
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, anthropic.WithHTTPClient(httpClient))
+	}
+
+	return anthropic.New(opts...)
+}
+
+func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
+	opts := []openai.Option{
+		openai.WithAPIKey(apiKey),
+		openai.WithUseResponsesAPI(),
+	}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, openai.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, openai.WithHeaders(headers))
+	}
+	if baseURL != "" {
+		opts = append(opts, openai.WithBaseURL(baseURL))
+	}
+	return openai.New(opts...)
+}
+
+func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
+	opts := []openrouter.Option{
+		openrouter.WithAPIKey(apiKey),
+	}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, openrouter.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, openrouter.WithHeaders(headers))
+	}
+	return openrouter.New(opts...)
+}
+
+func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
+	opts := []openaicompat.Option{
+		openaicompat.WithBaseURL(baseURL),
+		openaicompat.WithAPIKey(apiKey),
+	}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, openaicompat.WithHeaders(headers))
+	}
+
+	for extraKey, extraValue := range extraBody {
+		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
+	}
+
+	return openaicompat.New(opts...)
+}
+
+func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
+	opts := []azure.Option{
+		azure.WithBaseURL(baseURL),
+		azure.WithAPIKey(apiKey),
+		azure.WithUseResponsesAPI(),
+	}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, azure.WithHTTPClient(httpClient))
+	}
+	if options == nil {
+		options = make(map[string]string)
+	}
+	if apiVersion, ok := options["apiVersion"]; ok {
+		opts = append(opts, azure.WithAPIVersion(apiVersion))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, azure.WithHeaders(headers))
+	}
+
+	return azure.New(opts...)
+}
+
+func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
+	var opts []bedrock.Option
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, bedrock.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, bedrock.WithHeaders(headers))
+	}
+	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
+	if bearerToken != "" {
+		opts = append(opts, bedrock.WithAPIKey(bearerToken))
+	}
+	return bedrock.New(opts...)
+}
+
+func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
+	opts := []google.Option{
+		google.WithBaseURL(baseURL),
+		google.WithGeminiAPIKey(apiKey),
+	}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, google.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, google.WithHeaders(headers))
+	}
+	return google.New(opts...)
+}
+
+func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
+	opts := []google.Option{}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, google.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, google.WithHeaders(headers))
+	}
+
+	project := options["project"]
+	location := options["location"]
+
+	opts = append(opts, google.WithVertex(project, location))
+
+	return google.New(opts...)
+}
+
+func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
+	if model.Think {
+		return true
+	}
+
+	if model.ProviderOptions == nil {
+		return false
+	}
+
+	opts, err := anthropic.ParseOptions(model.ProviderOptions)
+	if err != nil {
+		return false
+	}
+	if opts.Thinking != nil {
+		return true
+	}
+	return false
+}
+
+func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
+	headers := maps.Clone(providerCfg.ExtraHeaders)
+	if headers == nil {
+		headers = make(map[string]string)
+	}
+
+	// handle special headers for anthropic
+	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
+		if v, ok := headers["anthropic-beta"]; ok {
+			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
+		} else {
+			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
+		}
+	}
+
+	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
+	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
+
+	switch providerCfg.Type {
+	case openai.Name:
+		return c.buildOpenaiProvider(baseURL, apiKey, headers)
+	case anthropic.Name:
+		return c.buildAnthropicProvider(baseURL, apiKey, headers)
+	case openrouter.Name:
+		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
+	case azure.Name:
+		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
+	case bedrock.Name:
+		return c.buildBedrockProvider(headers)
+	case google.Name:
+		return c.buildGoogleProvider(baseURL, apiKey, headers)
+	case "google-vertex":
+		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
+	case openaicompat.Name:
+		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
+	default:
+		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
+	}
+}
+
+func isExactoSupported(modelID string) bool {
+	supportedModels := []string{
+		"moonshotai/kimi-k2-0905",
+		"deepseek/deepseek-v3.1-terminus",
+		"z-ai/glm-4.6",
+		"openai/gpt-oss-120b",
+		"qwen/qwen3-coder",
+	}
+	return slices.Contains(supportedModels, modelID)
+}
+
+func (c *coordinator) Cancel(sessionID string) {
+	c.currentAgent.Cancel(sessionID)
+}
+
+func (c *coordinator) CancelAll() {
+	c.currentAgent.CancelAll()
+}
+
+func (c *coordinator) ClearQueue(sessionID string) {
+	c.currentAgent.ClearQueue(sessionID)
+}
+
+func (c *coordinator) IsBusy() bool {
+	return c.currentAgent.IsBusy()
+}
+
+func (c *coordinator) IsSessionBusy(sessionID string) bool {
+	return c.currentAgent.IsSessionBusy(sessionID)
+}
+
+func (c *coordinator) Model() Model {
+	return c.currentAgent.Model()
+}
+
+func (c *coordinator) UpdateModels(ctx context.Context) error {
+	// build the models again so we make sure we get the latest config
+	large, small, err := c.buildAgentModels(ctx)
+	if err != nil {
+		return err
+	}
+	c.currentAgent.SetModels(large, small)
+
+	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
+	if !ok {
+		return errors.New("coder agent not configured")
+	}
+
+	tools, err := c.buildTools(ctx, agentCfg)
+	if err != nil {
+		return err
+	}
+	c.currentAgent.SetTools(tools)
+	return nil
+}
+
+func (c *coordinator) QueuedPrompts(sessionID string) int {
+	return c.currentAgent.QueuedPrompts(sessionID)
+}
+
+func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
+	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
+	if !ok {
+		return errors.New("model provider not configured")
+	}
+	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
+}
diff --git a/internal/llm/agent/errors.go b/internal/agent/errors.go
similarity index 74%
rename from internal/llm/agent/errors.go
rename to internal/agent/errors.go
index 0e2f983d..1b4f0dfc 100644
--- a/internal/llm/agent/errors.go
+++ b/internal/agent/errors.go
@@ -8,6 +8,8 @@ import (
 var (
 	ErrRequestCancelled = errors.New("request canceled by user")
 	ErrSessionBusy      = errors.New("session is currently processing another request")
+	ErrEmptyPrompt      = errors.New("prompt is empty")
+	ErrSessionMissing   = errors.New("session id is missing")
 )
 
 func isCancelledErr(err error) bool {
diff --git a/internal/agent/event.go b/internal/agent/event.go
new file mode 100644
index 00000000..bf36ec84
--- /dev/null
+++ b/internal/agent/event.go
@@ -0,0 +1,51 @@
+package agent
+
+import (
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/event"
+)
+
+func (a sessionAgent) eventPromptSent(sessionID string) {
+	event.PromptSent(
+		a.eventCommon(sessionID, a.largeModel)...,
+	)
+}
+
+func (a sessionAgent) eventPromptResponded(sessionID string, duration time.Duration) {
+	event.PromptResponded(
+		append(
+			a.eventCommon(sessionID, a.largeModel),
+			"prompt duration pretty", duration.String(),
+			"prompt duration in seconds", int64(duration.Seconds()),
+		)...,
+	)
+}
+
+func (a sessionAgent) eventTokensUsed(sessionID string, model Model, usage fantasy.Usage, cost float64) {
+	event.TokensUsed(
+		append(
+			a.eventCommon(sessionID, model),
+			"input tokens", usage.InputTokens,
+			"output tokens", usage.OutputTokens,
+			"cache read tokens", usage.CacheReadTokens,
+			"cache creation tokens", usage.CacheCreationTokens,
+			"total tokens", usage.InputTokens+usage.OutputTokens+usage.CacheReadTokens+usage.CacheCreationTokens,
+			"cost", cost,
+		)...,
+	)
+}
+
+func (a sessionAgent) eventCommon(sessionID string, model Model) []any {
+	m := model.ModelCfg
+
+	return []any{
+		"session id", sessionID,
+		"provider", m.Provider,
+		"model", m.Model,
+		"reasoning effort", m.ReasoningEffort,
+		"thinking mode", m.Think,
+		"yolo mode", a.isYolo,
+	}
+}
diff --git a/internal/agent/prompt/prompt.go b/internal/agent/prompt/prompt.go
new file mode 100644
index 00000000..d10fbcae
--- /dev/null
+++ b/internal/agent/prompt/prompt.go
@@ -0,0 +1,248 @@
+package prompt
+
+import (
+	"cmp"
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"text/template"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/shell"
+)
+
+// Prompt represents a template-based prompt generator.
+type Prompt struct {
+	name       string
+	template   string
+	now        func() time.Time
+	platform   string
+	workingDir string
+}
+
+type PromptDat struct {
+	Provider     string
+	Model        string
+	Config       config.Config
+	WorkingDir   string
+	IsGitRepo    bool
+	Platform     string
+	Date         string
+	GitStatus    string
+	ContextFiles []ContextFile
+}
+
+type ContextFile struct {
+	Path    string
+	Content string
+}
+
+type Option func(*Prompt)
+
+func WithTimeFunc(fn func() time.Time) Option {
+	return func(p *Prompt) {
+		p.now = fn
+	}
+}
+
+func WithPlatform(platform string) Option {
+	return func(p *Prompt) {
+		p.platform = platform
+	}
+}
+
+func WithWorkingDir(workingDir string) Option {
+	return func(p *Prompt) {
+		p.workingDir = workingDir
+	}
+}
+
+func NewPrompt(name, promptTemplate string, opts ...Option) (*Prompt, error) {
+	p := &Prompt{
+		name:     name,
+		template: promptTemplate,
+		now:      time.Now,
+	}
+	for _, opt := range opts {
+		opt(p)
+	}
+	return p, nil
+}
+
+func (p *Prompt) Build(ctx context.Context, provider, model string, cfg config.Config) (string, error) {
+	t, err := template.New(p.name).Parse(p.template)
+	if err != nil {
+		return "", fmt.Errorf("parsing template: %w", err)
+	}
+	var sb strings.Builder
+	d, err := p.promptData(ctx, provider, model, cfg)
+	if err != nil {
+		return "", err
+	}
+	if err := t.Execute(&sb, d); err != nil {
+		return "", fmt.Errorf("executing template: %w", err)
+	}
+
+	return sb.String(), nil
+}
+
+func processFile(filePath string) *ContextFile {
+	content, err := os.ReadFile(filePath)
+	if err != nil {
+		return nil
+	}
+	return &ContextFile{
+		Path:    filePath,
+		Content: string(content),
+	}
+}
+
+func processContextPath(p string, cfg config.Config) []ContextFile {
+	var contexts []ContextFile
+	fullPath := p
+	if !filepath.IsAbs(p) {
+		fullPath = filepath.Join(cfg.WorkingDir(), p)
+	}
+	info, err := os.Stat(fullPath)
+	if err != nil {
+		return contexts
+	}
+	if info.IsDir() {
+		filepath.WalkDir(fullPath, func(path string, d os.DirEntry, err error) error {
+			if err != nil {
+				return err
+			}
+			if !d.IsDir() {
+				if result := processFile(path); result != nil {
+					contexts = append(contexts, *result)
+				}
+			}
+			return nil
+		})
+	} else {
+		result := processFile(fullPath)
+		if result != nil {
+			contexts = append(contexts, *result)
+		}
+	}
+	return contexts
+}
+
+// expandPath expands ~ and environment variables in file paths
+func expandPath(path string, cfg config.Config) string {
+	path = home.Long(path)
+	// Handle environment variable expansion using the same pattern as config
+	if strings.HasPrefix(path, "$") {
+		if expanded, err := cfg.Resolver().ResolveValue(path); err == nil {
+			path = expanded
+		}
+	}
+
+	return path
+}
+
+func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg config.Config) (PromptDat, error) {
+	workingDir := cmp.Or(p.workingDir, cfg.WorkingDir())
+	platform := cmp.Or(p.platform, runtime.GOOS)
+
+	files := map[string][]ContextFile{}
+
+	for _, pth := range cfg.Options.ContextPaths {
+		expanded := expandPath(pth, cfg)
+		pathKey := strings.ToLower(expanded)
+		if _, ok := files[pathKey]; ok {
+			continue
+		}
+		content := processContextPath(expanded, cfg)
+		files[pathKey] = content
+	}
+
+	isGit := isGitRepo(cfg.WorkingDir())
+	data := PromptDat{
+		Provider:   provider,
+		Model:      model,
+		Config:     cfg,
+		WorkingDir: filepath.ToSlash(workingDir),
+		IsGitRepo:  isGit,
+		Platform:   platform,
+		Date:       p.now().Format("1/2/2006"),
+	}
+	if isGit {
+		var err error
+		data.GitStatus, err = getGitStatus(ctx, cfg.WorkingDir())
+		if err != nil {
+			return PromptDat{}, err
+		}
+	}
+
+	for _, contextFiles := range files {
+		data.ContextFiles = append(data.ContextFiles, contextFiles...)
+	}
+	return data, nil
+}
+
+func isGitRepo(dir string) bool {
+	_, err := os.Stat(filepath.Join(dir, ".git"))
+	return err == nil
+}
+
+func getGitStatus(ctx context.Context, dir string) (string, error) {
+	sh := shell.NewShell(&shell.Options{
+		WorkingDir: dir,
+	})
+	branch, err := getGitBranch(ctx, sh)
+	if err != nil {
+		return "", err
+	}
+	status, err := getGitStatusSummary(ctx, sh)
+	if err != nil {
+		return "", err
+	}
+	commits, err := getGitRecentCommits(ctx, sh)
+	if err != nil {
+		return "", err
+	}
+	return branch + status + commits, nil
+}
+
+func getGitBranch(ctx context.Context, sh *shell.Shell) (string, error) {
+	out, _, err := sh.Exec(ctx, "git branch --show-current 2>/dev/null")
+	if err != nil {
+		return "", nil
+	}
+	out = strings.TrimSpace(out)
+	if out == "" {
+		return "", nil
+	}
+	return fmt.Sprintf("Current branch: %s\n", out), nil
+}
+
+func getGitStatusSummary(ctx context.Context, sh *shell.Shell) (string, error) {
+	out, _, err := sh.Exec(ctx, "git status --short 2>/dev/null | head -20")
+	if err != nil {
+		return "", nil
+	}
+	out = strings.TrimSpace(out)
+	if out == "" {
+		return "Status: clean\n", nil
+	}
+	return fmt.Sprintf("Status:\n%s\n", out), nil
+}
+
+func getGitRecentCommits(ctx context.Context, sh *shell.Shell) (string, error) {
+	out, _, err := sh.Exec(ctx, "git log --oneline -n 3 2>/dev/null")
+	if err != nil || out == "" {
+		return "", nil
+	}
+	out = strings.TrimSpace(out)
+	return fmt.Sprintf("Recent commits:\n%s\n", out), nil
+}
+
+func (p *Prompt) Name() string {
+	return p.name
+}
diff --git a/internal/agent/prompts.go b/internal/agent/prompts.go
new file mode 100644
index 00000000..01be6561
--- /dev/null
+++ b/internal/agent/prompts.go
@@ -0,0 +1,36 @@
+package agent
+
+import (
+	_ "embed"
+
+	"github.com/charmbracelet/crush/internal/agent/prompt"
+)
+
+//go:embed templates/coder.md.tpl
+var coderPromptTmpl []byte
+
+//go:embed templates/task.md.tpl
+var taskPromptTmpl []byte
+
+//go:embed templates/initialize.md
+var initializePrompt []byte
+
+func coderPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
+	systemPrompt, err := prompt.NewPrompt("coder", string(coderPromptTmpl), opts...)
+	if err != nil {
+		return nil, err
+	}
+	return systemPrompt, nil
+}
+
+func taskPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
+	systemPrompt, err := prompt.NewPrompt("task", string(taskPromptTmpl), opts...)
+	if err != nil {
+		return nil, err
+	}
+	return systemPrompt, nil
+}
+
+func InitializePrompt() string {
+	return string(initializePrompt)
+}
diff --git a/internal/agent/templates/agent_tool.md b/internal/agent/templates/agent_tool.md
new file mode 100644
index 00000000..dd5f8f46
--- /dev/null
+++ b/internal/agent/templates/agent_tool.md
@@ -0,0 +1,15 @@
+Launch a new agent that has access to the following tools: GlobTool, GrepTool, LS, View. When you are searching for a keyword or file and are not confident that you will find the right match on the first try, use the Agent tool to perform the search for you.
+
+<usage>
+- If you are searching for a keyword like "config" or "logger", or for questions like "which file does X?", the Agent tool is strongly recommended
+- If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool, to find the match more quickly
+- If you are searching for a specific class definition like "class Foo", use the GlobTool tool instead, to find the match more quickly
+</usage>
+
+<usage_notes>
+1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
+2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
+3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
+4. The agent's outputs should generally be trusted
+5. IMPORTANT: The agent can not use Bash, Replace, Edit, so can not modify files. If you want to use these tools, use them directly instead of going through the agent.
+</usage_notes>
diff --git a/internal/agent/templates/agentic_fetch.md b/internal/agent/templates/agentic_fetch.md
new file mode 100644
index 00000000..06775e34
--- /dev/null
+++ b/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>
diff --git a/internal/agent/templates/agentic_fetch_prompt.md.tpl b/internal/agent/templates/agentic_fetch_prompt.md.tpl
new file mode 100644
index 00000000..18e27a9b
--- /dev/null
+++ b/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>
diff --git a/internal/agent/templates/coder.md.tpl b/internal/agent/templates/coder.md.tpl
new file mode 100644
index 00000000..363b59da
--- /dev/null
+++ b/internal/agent/templates/coder.md.tpl
@@ -0,0 +1,348 @@
+You are Crush, a powerful AI Assistant that runs in the CLI.
+
+<critical_rules>
+These rules override everything else. Follow them strictly:
+
+1. **ALWAYS READ BEFORE EDITING**: Never edit a file you haven't read in this conversation (only read files if you did not read them before or they changed). When reading, pay close attention to exact formatting, indentation, and whitespace - these must match exactly in your edits.
+2. **BE AUTONOMOUS**: Don't ask questions - search, read, decide, act. Complete the ENTIRE task before stopping. Never stop mid-task. Never refuse work based on scope or complexity - break it down and do it.
+3. **TEST AFTER CHANGES**: Run tests immediately after each modification
+4. **BE CONCISE**: Under 4 lines unless user asks for detail
+5. **USE EXACT MATCHES**: When editing, match text exactly including whitespace, indentation, and line breaks
+6. **NEVER COMMIT**: Unless user explicitly says "commit"
+7. **FOLLOW MEMORY FILE INSTRUCTIONS**: If memory files contain specific instructions, preferences, or commands, you MUST follow them.
+8. **NEVER ADD COMMENTS**: Only add comments if the user asked you to do so. When adding comments, focus on *why* not *what*. NEVER communicate with the user through code comments.
+9. **SECURITY FIRST**: Only assist with defensive security tasks. Refuse to create, modify, or improve code that may be used maliciously. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation.
+10. **NO URL GUESSING**: Never generate or guess URLs unless you are confident they are for helping with programming. Only use URLs provided by the user or found in local files.
+11. **NEVER PUSH TO REMOTE**: Don't push changes to remote repositories unless explicitly asked by the user.
+12. **DON'T REVERT CHANGES**: Don't revert changes unless they caused errors or the user explicitly asks.
+13. **COMPLETE THE TASK**: Never stop mid-task with "Next:" or "Will do:" statements. If you describe what needs to be done, DO IT immediately. Only stop when everything is finished.
+14. **NEVER REFUSE BASED ON SCOPE**: Never refuse tasks because they seem large or complex. Break them into steps and complete them. Only stop if you encounter actual blocking errors (missing dependencies, compile failures, etc.), not perceived difficulty.
+</critical_rules>
+
+<communication_style>
+Keep responses minimal:
+- Under 4 lines of text (tool use doesn't count)
+- No preamble ("Here's...", "I'll...")
+- No postamble ("Let me know...", "Hope this helps...")
+- One-word answers when possible
+- No emojis ever
+- No explanations unless user asks
+
+Examples:
+user: what is 2+2?
+assistant: 4
+
+user: list files in src/
+assistant: [uses ls tool]
+foo.c, bar.c, baz.c
+
+user: which file has the foo implementation?
+assistant: src/foo.c
+
+user: add error handling to the login function
+assistant: [searches for login, reads file, edits with exact match, runs tests]
+Done
+
+user: Where are errors from the client handled?
+assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.go:712.
+</communication_style>
+
+<code_references>
+When referencing specific functions or code locations, use the pattern `file_path:line_number` to help users navigate:
+- Example: "The error is handled in src/main.go:45"
+- Example: "See the implementation in pkg/utils/helper.go:123-145"
+</code_references>
+
+<workflow>
+For every task, follow this sequence internally (don't narrate it):
+
+**Before acting**:
+- Search codebase for relevant files
+- Read files to understand current state
+- Check memory for stored commands
+- Identify what needs to change
+- Use `git log` and `git blame` for additional context when needed
+
+**While acting**:
+- Read entire file before editing it
+- Before editing: verify exact whitespace and indentation from View output
+- Use exact text for find/replace (include whitespace)
+- Make one logical change at a time
+- After each change: run tests
+- If tests fail: fix immediately
+- If edit fails: read more context, don't guess - the text must match exactly
+- Keep going until query is completely resolved before yielding to user
+- For longer tasks, send brief progress updates (under 10 words) BUT IMMEDIATELY CONTINUE WORKING - progress updates are not stopping points
+
+**Before finishing**:
+- Verify ENTIRE query is resolved (not just first step)
+- All described next steps must be completed
+- Run lint/typecheck if in memory
+- Verify all changes work
+- Keep response under 4 lines
+
+**Key behaviors**:
+- Use find_references before changing shared code
+- Follow existing patterns (check similar files)
+- If stuck, try different approach (don't repeat failures)
+- Make decisions yourself (search first, don't ask)
+- Fix problems at root cause, not surface-level patches
+- Don't fix unrelated bugs or broken tests (mention them in final message if relevant)
+</workflow>
+
+<decision_making>
+**Make decisions autonomously** - don't ask when you can:
+- Search to find the answer
+- Read files to see patterns
+- Check similar code
+- Infer from context
+- Try most likely approach
+
+**Only stop/ask user if**:
+- Truly ambiguous business requirement
+- Multiple valid approaches with big tradeoffs
+- Could cause data loss
+- Exhausted all attempts and hit actual blocking errors
+
+**Never stop for**:
+- Task seems too large (break it down)
+- Multiple files to change (change them)
+- Concerns about "session limits" (no such limits exist)
+- Work will take many steps (do all the steps)
+
+Examples of autonomous decisions:
+- File location → search for similar files
+- Test command → check package.json/memory
+- Code style → read existing code
+- Library choice → check what's used
+- Naming → follow existing names
+</decision_making>
+
+<task_scope>
+**No task is too large**:
+- Break complex tasks into logical steps
+- Complete each step fully before moving to next
+- If a task has 10 parts, do all 10 parts
+- Don't estimate effort or refuse based on scope
+- Only stop if you hit actual errors (compile failures, missing files, etc.)
+
+**For large refactors or implementations**:
+- Start with core functionality
+- Build incrementally
+- Test at each step
+- Keep going until fully complete
+
+There are no "session limits" - continue until the task is done or you hit a real blocker.
+</task_scope>
+
+<editing_files>
+Critical: ALWAYS read files before editing them in this conversation.
+
+When using edit tools:
+1. Read the file first - note the EXACT indentation (spaces vs tabs, count)
+2. Copy the exact text including ALL whitespace, newlines, and indentation
+3. Include 3-5 lines of context before and after the target
+4. Verify your old_string would appear exactly once in the file
+5. If uncertain about whitespace, include more surrounding context
+6. Verify edit succeeded
+7. Run tests
+
+**Whitespace matters**:
+- Count spaces/tabs carefully (use View tool line numbers as reference)
+- Include blank lines if they exist
+- Match line endings exactly
+- When in doubt, include MORE context rather than less
+
+Efficiency tips:
+- Don't re-read files after successful edits (tool will fail if it didn't work)
+- Same applies for making folders, deleting files, etc.
+
+Common mistakes to avoid:
+- Editing without reading first
+- Approximate text matches
+- Wrong indentation (spaces vs tabs, wrong count)
+- Missing or extra blank lines
+- Not enough context (text appears multiple times)
+- Trimming whitespace that exists in the original
+- Not testing after changes
+</editing_files>
+
+<whitespace_and_exact_matching>
+The Edit tool is extremely literal. "Close enough" will fail.
+
+**Before every edit**:
+1. View the file and locate the exact lines to change
+2. Copy the text EXACTLY including:
+   - Every space and tab
+   - Every blank line
+   - Opening/closing braces position
+   - Comment formatting
+3. Include enough surrounding lines (3-5) to make it unique
+4. Double-check indentation level matches
+
+**Common failures**:
+- `func foo() {` vs `func foo(){` (space before brace)
+- Tab vs 4 spaces vs 2 spaces
+- Missing blank line before/after
+- `// comment` vs `//comment` (space after //)
+- Different number of spaces in indentation
+
+**If edit fails**:
+- View the file again at the specific location
+- Copy even more context
+- Check for tabs vs spaces
+- Verify line endings
+- Try including the entire function/block if needed
+- Never retry with guessed changes - get the exact text first
+</whitespace_and_exact_matching>
+
+<error_handling>
+When errors occur:
+1. Read complete error message
+2. Understand root cause
+3. Try different approach (don't repeat same action)
+4. Search for similar code that works
+5. Make targeted fix
+6. Test to verify
+
+Common errors:
+- Import/Module → check paths, spelling, what exists
+- Syntax → check brackets, indentation, typos
+- Tests fail → read test, see what it expects
+- File not found → use ls, check exact path
+
+**Edit tool "old_string not found"**:
+- View the file again at the target location
+- Copy the EXACT text including all whitespace
+- Include more surrounding context (full function if needed)
+- Check for tabs vs spaces, extra/missing blank lines
+- Count indentation spaces carefully
+- Don't retry with approximate matches - get the exact text
+</error_handling>
+
+<memory_instructions>
+Memory files store commands, preferences, and codebase info. Update them when you discover:
+- Build/test/lint commands
+- Code style preferences  
+- Important codebase patterns
+- Useful project information
+</memory_instructions>
+
+<code_conventions>
+Before writing code:
+1. Check if library exists (look at imports, package.json)
+2. Read similar code for patterns
+3. Match existing style
+4. Use same libraries/frameworks
+5. Follow security best practices (never log secrets)
+6. Don't use one-letter variable names unless requested
+
+Never assume libraries are available - verify first.
+
+**Ambition vs. precision**:
+- New projects → be creative and ambitious with implementation
+- Existing codebases → be surgical and precise, respect surrounding code
+- Don't change filenames or variables unnecessarily
+- Don't add formatters/linters/tests to codebases that don't have them
+</code_conventions>
+
+<testing>
+After significant changes:
+- Start testing as specific as possible to code changed, then broaden to build confidence
+- Use self-verification: write unit tests, add output logs, or use debug statements to verify your solutions
+- Run relevant test suite
+- If tests fail, fix before continuing
+- Check memory for test commands
+- Run lint/typecheck if available (on precise targets when possible)
+- For formatters: iterate max 3 times to get it right; if still failing, present correct solution and note formatting issue
+- Suggest adding commands to memory if not found
+- Don't fix unrelated bugs or test failures (not your responsibility)
+</testing>
+
+<tool_usage>
+- Search before assuming
+- Read files before editing
+- Always use absolute paths for file operations (editing, reading, writing)
+- Use Agent tool for complex searches
+- Run tools in parallel when safe (no dependencies)
+- When making multiple independent bash calls, send them in a single message with multiple tool calls for parallel execution
+- Summarize tool output for user (they don't see it)
+
+<bash_commands>
+When running non-trivial bash commands (especially those that modify the system):
+- Briefly explain what the command does and why you're running it
+- This ensures the user understands potentially dangerous operations
+- Simple read-only commands (ls, cat, etc.) don't need explanation
+- Use `&` for background processes that won't stop on their own (e.g., `node server.js &`)
+- Avoid interactive commands - use non-interactive versions (e.g., `npm init -y` not `npm init`)
+- Combine related commands to save time (e.g., `git status && git diff HEAD && git log -n 3`)
+</bash_commands>
+</tool_usage>
+
+<proactiveness>
+Balance autonomy with user intent:
+- When asked to do something → do it fully (including ALL follow-ups and "next steps")
+- Never describe what you'll do next - just do it
+- When asked how to approach → explain first, don't auto-implement
+- After completing work → stop, don't explain (unless asked)
+- Don't surprise user with unexpected actions
+</proactiveness>
+
+<final_answers>
+Adapt verbosity to match the work completed:
+
+**Default (under 4 lines)**:
+- Simple questions or single-file changes
+- Casual conversation, greetings, acknowledgements
+- One-word answers when possible
+
+**More detail allowed (up to 10-15 lines)**:
+- Large multi-file changes that need walkthrough
+- Complex refactoring where rationale adds value
+- Tasks where understanding the approach is important
+- When mentioning unrelated bugs/issues found
+- Suggesting logical next steps user might want
+
+**What to include in verbose answers**:
+- Brief summary of what was done and why
+- Key files/functions changed (with `file:line` references)
+- Any important decisions or tradeoffs made
+- Next steps or things user should verify
+- Issues found but not fixed
+
+**What to avoid**:
+- Don't show full file contents unless explicitly asked
+- Don't explain how to save files or copy code (user has access to your work)
+- Don't use "Here's what I did" or "Let me know if..." style preambles/postambles
+- Keep tone direct and factual, like handing off work to a teammate
+</final_answers>
+
+<env>
+Working directory: {{.WorkingDir}}
+Is directory a git repo: {{if .IsGitRepo}}yes{{else}}no{{end}}
+Platform: {{.Platform}}
+Today's date: {{.Date}}
+{{if .GitStatus}}
+
+Git status (snapshot at conversation start - may be outdated):
+{{.GitStatus}}
+{{end}}
+</env>
+
+{{if gt (len .Config.LSP) 0}}
+<lsp>
+Diagnostics (lint/typecheck) included in tool output.
+- Fix issues in files you changed
+- Ignore issues in files you didn't touch (unless user asks)
+</lsp>
+{{end}}
+
+{{if .ContextFiles}}
+<memory>
+{{range .ContextFiles}}
+<file path="{{.Path}}">
+{{.Content}}
+</file>
+{{end}}
+</memory>
+{{end}}
diff --git a/internal/agent/templates/initialize.md b/internal/agent/templates/initialize.md
new file mode 100644
index 00000000..5eb1636c
--- /dev/null
+++ b/internal/agent/templates/initialize.md
@@ -0,0 +1,27 @@
+Analyze this codebase and create/update **CRUSH.md** to help future agents work effectively in this repository.
+
+**First**: Check if directory is empty or contains only config files. If so, stop and say "Directory appears empty or only contains config. Add source code first, then run this command to generate CRUSH.md."
+
+**Goal**: Document what an agent needs to know to work in this codebase - commands, patterns, conventions, gotchas.
+
+**Discovery process**:
+
+1. Check directory contents with `ls`
+2. Look for existing rule files (`.cursor/rules/*.md`, `.cursorrules`, `.github/copilot-instructions.md`, `claude.md`, `agents.md`) - only read if they exist
+3. Identify project type from config files and directory structure
+4. Find build/test/lint commands from config files, scripts, Makefiles, or CI configs
+5. Read representative source files to understand code patterns
+6. If CRUSH.md exists, read and improve it
+
+**Content to include**:
+
+- Essential commands (build, test, run, deploy, etc.) - whatever is relevant for this project
+- Code organization and structure
+- Naming conventions and style patterns
+- Testing approach and patterns
+- Important gotchas or non-obvious patterns
+- Any project-specific context from existing rule files
+
+**Format**: Clear markdown sections. Use your judgment on structure based on what you find. Aim for completeness over brevity - include everything an agent would need to know.
+
+**Critical**: Only document what you actually observe. Never invent commands, patterns, or conventions. If you can't find something, don't include it.
diff --git a/internal/agent/templates/summary.md b/internal/agent/templates/summary.md
new file mode 100644
index 00000000..937c59d7
--- /dev/null
+++ b/internal/agent/templates/summary.md
@@ -0,0 +1,48 @@
+You are summarizing a conversation to preserve context for continuing work later.
+
+**Critical**: This summary will be the ONLY context available when the conversation resumes. Assume all previous messages will be lost. Be thorough.
+
+**Required sections**:
+
+## Current State
+
+- What task is being worked on (exact user request)
+- Current progress and what's been completed
+- What's being worked on right now (incomplete work)
+- What remains to be done (specific next steps, not vague)
+
+## Files & Changes
+
+- Files that were modified (with brief description of changes)
+- Files that were read/analyzed (why they're relevant)
+- Key files not yet touched but will need changes
+- File paths and line numbers for important code locations
+
+## Technical Context
+
+- Architecture decisions made and why
+- Patterns being followed (with examples)
+- Libraries/frameworks being used
+- Commands that worked (exact commands with context)
+- Commands that failed (what was tried and why it didn't work)
+- Environment details (language versions, dependencies, etc.)
+
+## Strategy & Approach
+
+- Overall approach being taken
+- Why this approach was chosen over alternatives
+- Key insights or gotchas discovered
+- Assumptions made
+- Any blockers or risks identified
+
+## Exact Next Steps
+
+Be specific. Don't write "implement authentication" - write:
+
+1. Add JWT middleware to src/middleware/auth.js:15
+2. Update login handler in src/routes/user.js:45 to return token
+3. Test with: npm test -- auth.test.js
+
+**Tone**: Write as if briefing a teammate taking over mid-task. Include everything they'd need to continue without asking questions.
+
+**Length**: No limit. Err on the side of too much detail rather than too little. Critical context is worth the tokens.
diff --git a/internal/agent/templates/task.md.tpl b/internal/agent/templates/task.md.tpl
new file mode 100644
index 00000000..bc5947a3
--- /dev/null
+++ b/internal/agent/templates/task.md.tpl
@@ -0,0 +1,15 @@
+You are an agent for Crush. Given the user's prompt, you should use the tools available to you to answer the user's question.
+
+<rules>
+1. You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
+2. When relevant, share file names and code snippets relevant to the query
+3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.
+</rules>
+
+<env>
+Working directory: {{.WorkingDir}}
+Is directory a git repo: {{if .IsGitRepo}} yes {{else}} no {{end}}
+Platform: {{.Platform}}
+Today's date: {{.Date}}
+</env>
+
diff --git a/internal/llm/prompt/title.md b/internal/agent/templates/title.md
similarity index 95%
rename from internal/llm/prompt/title.md
rename to internal/agent/templates/title.md
index 6da44069..aaf2a49e 100644
--- a/internal/llm/prompt/title.md
+++ b/internal/agent/templates/title.md
@@ -1,8 +1,10 @@
 you will generate a short title based on the first message a user begins a conversation with
 
+<rules>
 - ensure it is not more than 50 characters long
 - the title should be a summary of the user's message
 - it should be one line long
 - do not use quotes or colons
 - the entire text you return will be used as the title
 - never return anything that is more than one sentence (one line) long
+</rules>
diff --git a/internal/agent/tools/bash.go b/internal/agent/tools/bash.go
new file mode 100644
index 00000000..e646e14d
--- /dev/null
+++ b/internal/agent/tools/bash.go
@@ -0,0 +1,330 @@
+package tools
+
+import (
+	"bytes"
+	"context"
+	_ "embed"
+	"fmt"
+	"html/template"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/shell"
+)
+
+type BashParams struct {
+	Command     string `json:"command" description:"The command to execute"`
+	Description string `json:"description,omitempty" description:"A brief description of what the command does"`
+	Timeout     int    `json:"timeout,omitempty" description:"Optional timeout in milliseconds (max 600000)"`
+}
+
+type BashPermissionsParams struct {
+	Command     string `json:"command"`
+	Description string `json:"description"`
+	Timeout     int    `json:"timeout"`
+}
+
+type BashResponseMetadata struct {
+	StartTime        int64  `json:"start_time"`
+	EndTime          int64  `json:"end_time"`
+	Output           string `json:"output"`
+	Description      string `json:"description"`
+	WorkingDirectory string `json:"working_directory"`
+}
+
+const (
+	BashToolName = "bash"
+
+	DefaultTimeout  = 1 * 60 * 1000  // 1 minutes in milliseconds
+	MaxTimeout      = 10 * 60 * 1000 // 10 minutes in milliseconds
+	MaxOutputLength = 30000
+	BashNoOutput    = "no output"
+)
+
+//go:embed bash.tpl
+var bashDescriptionTmpl []byte
+
+var bashDescriptionTpl = template.Must(
+	template.New("bashDescription").
+		Parse(string(bashDescriptionTmpl)),
+)
+
+type bashDescriptionData struct {
+	BannedCommands  string
+	MaxOutputLength int
+	Attribution     config.Attribution
+}
+
+var bannedCommands = []string{
+	// Network/Download tools
+	"alias",
+	"aria2c",
+	"axel",
+	"chrome",
+	"curl",
+	"curlie",
+	"firefox",
+	"http-prompt",
+	"httpie",
+	"links",
+	"lynx",
+	"nc",
+	"safari",
+	"scp",
+	"ssh",
+	"telnet",
+	"w3m",
+	"wget",
+	"xh",
+
+	// System administration
+	"doas",
+	"su",
+	"sudo",
+
+	// Package managers
+	"apk",
+	"apt",
+	"apt-cache",
+	"apt-get",
+	"dnf",
+	"dpkg",
+	"emerge",
+	"home-manager",
+	"makepkg",
+	"opkg",
+	"pacman",
+	"paru",
+	"pkg",
+	"pkg_add",
+	"pkg_delete",
+	"portage",
+	"rpm",
+	"yay",
+	"yum",
+	"zypper",
+
+	// System modification
+	"at",
+	"batch",
+	"chkconfig",
+	"crontab",
+	"fdisk",
+	"mkfs",
+	"mount",
+	"parted",
+	"service",
+	"systemctl",
+	"umount",
+
+	// Network configuration
+	"firewall-cmd",
+	"ifconfig",
+	"ip",
+	"iptables",
+	"netstat",
+	"pfctl",
+	"route",
+	"ufw",
+}
+
+func bashDescription(attribution *config.Attribution) string {
+	bannedCommandsStr := strings.Join(bannedCommands, ", ")
+	var out bytes.Buffer
+	if err := bashDescriptionTpl.Execute(&out, bashDescriptionData{
+		BannedCommands:  bannedCommandsStr,
+		MaxOutputLength: MaxOutputLength,
+		Attribution:     *attribution,
+	}); err != nil {
+		// this should never happen.
+		panic("failed to execute bash description template: " + err.Error())
+	}
+	return out.String()
+}
+
+func blockFuncs() []shell.BlockFunc {
+	return []shell.BlockFunc{
+		shell.CommandsBlocker(bannedCommands),
+
+		// System package managers
+		shell.ArgumentsBlocker("apk", []string{"add"}, nil),
+		shell.ArgumentsBlocker("apt", []string{"install"}, nil),
+		shell.ArgumentsBlocker("apt-get", []string{"install"}, nil),
+		shell.ArgumentsBlocker("dnf", []string{"install"}, nil),
+		shell.ArgumentsBlocker("pacman", nil, []string{"-S"}),
+		shell.ArgumentsBlocker("pkg", []string{"install"}, nil),
+		shell.ArgumentsBlocker("yum", []string{"install"}, nil),
+		shell.ArgumentsBlocker("zypper", []string{"install"}, nil),
+
+		// Language-specific package managers
+		shell.ArgumentsBlocker("brew", []string{"install"}, nil),
+		shell.ArgumentsBlocker("cargo", []string{"install"}, nil),
+		shell.ArgumentsBlocker("gem", []string{"install"}, nil),
+		shell.ArgumentsBlocker("go", []string{"install"}, nil),
+		shell.ArgumentsBlocker("npm", []string{"install"}, []string{"--global"}),
+		shell.ArgumentsBlocker("npm", []string{"install"}, []string{"-g"}),
+		shell.ArgumentsBlocker("pip", []string{"install"}, []string{"--user"}),
+		shell.ArgumentsBlocker("pip3", []string{"install"}, []string{"--user"}),
+		shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"--global"}),
+		shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"-g"}),
+		shell.ArgumentsBlocker("yarn", []string{"global", "add"}, nil),
+
+		// `go test -exec` can run arbitrary commands
+		shell.ArgumentsBlocker("go", []string{"test"}, []string{"-exec"}),
+	}
+}
+
+func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution) fantasy.AgentTool {
+	// Set up command blocking on the persistent shell
+	persistentShell := shell.GetPersistentShell(workingDir)
+	persistentShell.SetBlockFuncs(blockFuncs())
+	return fantasy.NewAgentTool(
+		BashToolName,
+		string(bashDescription(attribution)),
+		func(ctx context.Context, params BashParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.Timeout > MaxTimeout {
+				params.Timeout = MaxTimeout
+			} else if params.Timeout <= 0 {
+				params.Timeout = DefaultTimeout
+			}
+
+			if params.Command == "" {
+				return fantasy.NewTextErrorResponse("missing command"), nil
+			}
+
+			isSafeReadOnly := false
+			cmdLower := strings.ToLower(params.Command)
+
+			for _, safe := range safeCommands {
+				if strings.HasPrefix(cmdLower, safe) {
+					if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
+						isSafeReadOnly = true
+						break
+					}
+				}
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
+			}
+			if !isSafeReadOnly {
+				shell := shell.GetPersistentShell(workingDir)
+				p := permissions.Request(
+					permission.CreatePermissionRequest{
+						SessionID:   sessionID,
+						Path:        shell.GetWorkingDir(),
+						ToolCallID:  call.ID,
+						ToolName:    BashToolName,
+						Action:      "execute",
+						Description: fmt.Sprintf("Execute command: %s", params.Command),
+						Params: BashPermissionsParams{
+							Command:     params.Command,
+							Description: params.Description,
+						},
+					},
+				)
+				if !p {
+					return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+				}
+			}
+			startTime := time.Now()
+			if params.Timeout > 0 {
+				var cancel context.CancelFunc
+				ctx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Millisecond)
+				defer cancel()
+			}
+
+			persistentShell := shell.GetPersistentShell(workingDir)
+			stdout, stderr, err := persistentShell.Exec(ctx, params.Command)
+
+			// Get the current working directory after command execution
+			currentWorkingDir := persistentShell.GetWorkingDir()
+			interrupted := shell.IsInterrupt(err)
+			exitCode := shell.ExitCode(err)
+			if exitCode == 0 && !interrupted && err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error executing command: %w", err)
+			}
+
+			stdout = truncateOutput(stdout)
+			stderr = truncateOutput(stderr)
+
+			errorMessage := stderr
+			if errorMessage == "" && err != nil {
+				errorMessage = err.Error()
+			}
+
+			if interrupted {
+				if errorMessage != "" {
+					errorMessage += "\n"
+				}
+				errorMessage += "Command was aborted before completion"
+			} else if exitCode != 0 {
+				if errorMessage != "" {
+					errorMessage += "\n"
+				}
+				errorMessage += fmt.Sprintf("Exit code %d", exitCode)
+			}
+
+			hasBothOutputs := stdout != "" && stderr != ""
+
+			if hasBothOutputs {
+				stdout += "\n"
+			}
+
+			if errorMessage != "" {
+				stdout += "\n" + errorMessage
+			}
+
+			metadata := BashResponseMetadata{
+				StartTime:        startTime.UnixMilli(),
+				EndTime:          time.Now().UnixMilli(),
+				Output:           stdout,
+				Description:      params.Description,
+				WorkingDirectory: currentWorkingDir,
+			}
+			if stdout == "" {
+				return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
+			}
+			stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(currentWorkingDir))
+			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
+		})
+}
+
+func truncateOutput(content string) string {
+	if len(content) <= MaxOutputLength {
+		return content
+	}
+
+	halfLength := MaxOutputLength / 2
+	start := content[:halfLength]
+	end := content[len(content)-halfLength:]
+
+	truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
+	return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
+}
+
+func countLines(s string) int {
+	if s == "" {
+		return 0
+	}
+	return len(strings.Split(s, "\n"))
+}
+
+func normalizeWorkingDir(path string) string {
+	if runtime.GOOS == "windows" {
+		cwd, err := os.Getwd()
+		if err != nil {
+			cwd = "C:"
+		}
+		path = strings.ReplaceAll(path, filepath.VolumeName(cwd), "")
+	}
+
+	return filepath.ToSlash(path)
+}
diff --git a/internal/agent/tools/bash.tpl b/internal/agent/tools/bash.tpl
new file mode 100644
index 00000000..05b34517
--- /dev/null
+++ b/internal/agent/tools/bash.tpl
@@ -0,0 +1,116 @@
+Executes bash commands in persistent shell session with timeout and security measures.
+
+<cross_platform>
+Uses mvdan/sh interpreter (Bash-compatible on all platforms including Windows).
+Use forward slashes for paths: "ls C:/foo/bar" not "ls C:\foo\bar".
+Common shell builtins and core utils available on Windows.
+</cross_platform>
+
+<execution_steps>
+1. Directory Verification: If creating directories/files, use LS tool to verify parent exists
+2. Security Check: Banned commands ({{ .BannedCommands }}) return error - explain to user. Safe read-only commands execute without prompts
+3. Command Execution: Execute with proper quoting, capture output
+4. Output Processing: Truncate if exceeds {{ .MaxOutputLength }} characters
+5. Return Result: Include errors, metadata with <cwd></cwd> tags
+</execution_steps>
+
+<usage_notes>
+- Command required, timeout optional (max 600000ms/10min, default 30min if unspecified)
+- IMPORTANT: Use Grep/Glob/Agent tools instead of 'find'/'grep'. Use View/LS tools instead of 'cat'/'head'/'tail'/'ls'
+- Chain with ';' or '&&', avoid newlines except in quoted strings
+- Shell state persists (env vars, virtual envs, cwd, etc.)
+- Prefer absolute paths over 'cd' (use 'cd' only if user explicitly requests)
+</usage_notes>
+
+<git_commits>
+When user asks to create git commit:
+
+1. Single message with three tool_use blocks (IMPORTANT for speed):
+   - git status (untracked files)
+   - git diff (staged/unstaged changes)
+   - git log (recent commit message style)
+
+2. Add relevant untracked files to staging. Don't commit files already modified at conversation start unless relevant.
+
+3. Analyze staged changes in <commit_analysis> tags:
+   - List changed/added files, summarize nature (feature/enhancement/bug fix/refactoring/test/docs)
+   - Brainstorm purpose/motivation, assess project impact, check for sensitive info
+   - Don't use tools beyond git context
+   - Draft concise (1-2 sentences) message focusing on "why" not "what"
+   - Use clear language, accurate reflection ("add"=new feature, "update"=enhancement, "fix"=bug fix)
+   - Avoid generic messages, review draft
+
+4. Create commit with Crush signature using HEREDOC:
+   git commit -m "$(cat <<'EOF'
+   Commit message here.
+{{ if .Attribution.GeneratedWith}}
+   💘 Generated with Crush
+{{ end }}
+{{ if .Attribution.CoAuthoredBy}}
+   Co-Authored-By: Crush <crush@charm.land>
+{{ end }}
+   EOF
+   )"
+
+5. If pre-commit hook fails, retry ONCE. If fails again, hook preventing commit. If succeeds but files modified, MUST amend.
+
+6. Run git status to verify.
+
+Notes: Use "git commit -am" when possible, don't stage unrelated files, NEVER update config, don't push, no -i flags, no empty commits, return empty response.
+</git_commits>
+
+<pull_requests>
+Use gh command for ALL GitHub tasks. When user asks to create PR:
+
+1. Single message with multiple tool_use blocks (VERY IMPORTANT for speed):
+   - git status (untracked files)
+   - git diff (staged/unstaged changes)
+   - Check if branch tracks remote and is up to date
+   - git log and 'git diff main...HEAD' (full commit history from main divergence)
+
+2. Create new branch if needed
+3. Commit changes if needed
+4. Push to remote with -u flag if needed
+
+5. Analyze changes in <pr_analysis> tags:
+   - List commits since diverging from main
+   - Summarize nature of changes
+   - Brainstorm purpose/motivation
+   - Assess project impact
+   - Don't use tools beyond git context
+   - Check for sensitive information
+   - Draft concise (1-2 bullet points) PR summary focusing on "why"
+   - Ensure summary reflects ALL changes since main divergence
+   - Clear, concise language
+   - Accurate reflection of changes and purpose
+   - Avoid generic summaries
+   - Review draft
+
+6. Create PR with gh pr create using HEREDOC:
+   gh pr create --title "title" --body "$(cat <<'EOF'
+
+   ## Summary
+
+   <1-3 bullet points>
+
+   ## Test plan
+
+   [Checklist of TODOs...]
+
+{{ if .Attribution.GeneratedWith}}
+   💘 Generated with Crush
+{{ end }}
+
+   EOF
+   )"
+
+Important:
+
+- Return empty response - user sees gh output
+- Never update git config
+</pull_requests>
+
+<examples>
+Good: pytest /foo/bar/tests
+Bad: cd /foo/bar && pytest tests
+</examples>
diff --git a/internal/llm/tools/diagnostics.go b/internal/agent/tools/diagnostics.go
similarity index 78%
rename from internal/llm/tools/diagnostics.go
rename to internal/agent/tools/diagnostics.go
index c2625e94..f44c25b3 100644
--- a/internal/llm/tools/diagnostics.go
+++ b/internal/agent/tools/diagnostics.go
@@ -3,24 +3,20 @@ package tools
 import (
 	"context"
 	_ "embed"
-	"encoding/json"
 	"fmt"
 	"log/slog"
 	"sort"
 	"strings"
 	"time"
 
+	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
 
 type DiagnosticsParams struct {
-	FilePath string `json:"file_path"`
-}
-
-type diagnosticsTool struct {
-	lspClients *csync.Map[string, *lsp.Client]
+	FilePath string `json:"file_path,omitempty" description:"The path to the file to get diagnostics for (leave w empty for project diagnostics)"`
 }
 
 const DiagnosticsToolName = "lsp_diagnostics"
@@ -28,42 +24,18 @@ const DiagnosticsToolName = "lsp_diagnostics"
 //go:embed diagnostics.md
 var diagnosticsDescription []byte
 
-func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) BaseTool {
-	return &diagnosticsTool{
-		lspClients,
-	}
-}
-
-func (b *diagnosticsTool) Name() string {
-	return DiagnosticsToolName
-}
-
-func (b *diagnosticsTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        DiagnosticsToolName,
-		Description: string(diagnosticsDescription),
-		Parameters: map[string]any{
-			"file_path": map[string]any{
-				"type":        "string",
-				"description": "The path to the file to get diagnostics for (leave w empty for project diagnostics)",
-			},
-		},
-		Required: []string{},
-	}
-}
-
-func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params DiagnosticsParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	if b.lspClients.Len() == 0 {
-		return NewTextErrorResponse("no LSP clients available"), nil
-	}
-	notifyLSPs(ctx, b.lspClients, params.FilePath)
-	output := getDiagnostics(params.FilePath, b.lspClients)
-	return NewTextResponse(output), nil
+func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		DiagnosticsToolName,
+		string(diagnosticsDescription),
+		func(ctx context.Context, params DiagnosticsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if lspClients.Len() == 0 {
+				return fantasy.NewTextErrorResponse("no LSP clients available"), nil
+			}
+			notifyLSPs(ctx, lspClients, params.FilePath)
+			output := getDiagnostics(params.FilePath, lspClients)
+			return fantasy.NewTextResponse(output), nil
+		})
 }
 
 func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filepath string) {
@@ -122,7 +94,7 @@ func getDiagnostics(filePath string, lsps *csync.Map[string, *lsp.Client]) strin
 	}
 
 	out := output.String()
-	slog.Info("Diagnostics", "output", fmt.Sprintf("%q", out))
+	slog.Info("Diagnostics", "output", out)
 	return out
 }
 
diff --git a/internal/agent/tools/diagnostics.md b/internal/agent/tools/diagnostics.md
new file mode 100644
index 00000000..37a893ae
--- /dev/null
+++ b/internal/agent/tools/diagnostics.md
@@ -0,0 +1,24 @@
+Get diagnostics for file and/or project.
+
+<usage>
+- Provide file path to get diagnostics for that file
+- Leave path empty to get diagnostics for entire project
+- Results displayed in structured format with severity levels
+</usage>
+
+<features>
+- Displays errors, warnings, and hints
+- Groups diagnostics by severity
+- Provides detailed information about each diagnostic
+</features>
+
+<limitations>
+- Results limited to diagnostics provided by LSP clients
+- May not cover all possible code issues
+- Does not provide suggestions for fixing issues
+</limitations>
+
+<tips>
+- Use with other tools for comprehensive code review
+- Combine with LSP client for real-time diagnostics
+</tips>
diff --git a/internal/agent/tools/download.go b/internal/agent/tools/download.go
new file mode 100644
index 00000000..2dfd43d6
--- /dev/null
+++ b/internal/agent/tools/download.go
@@ -0,0 +1,157 @@
+package tools
+
+import (
+	"cmp"
+	"context"
+	_ "embed"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+type DownloadParams struct {
+	URL      string `json:"url" description:"The URL to download from"`
+	FilePath string `json:"file_path" description:"The local file path where the downloaded content should be saved"`
+	Timeout  int    `json:"timeout,omitempty" description:"Optional timeout in seconds (max 600)"`
+}
+
+type DownloadPermissionsParams struct {
+	URL      string `json:"url"`
+	FilePath string `json:"file_path"`
+	Timeout  int    `json:"timeout,omitempty"`
+}
+
+const DownloadToolName = "download"
+
+//go:embed download.md
+var downloadDescription []byte
+
+func NewDownloadTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
+	if client == nil {
+		client = &http.Client{
+			Timeout: 5 * time.Minute, // Default 5 minute timeout for downloads
+			Transport: &http.Transport{
+				MaxIdleConns:        100,
+				MaxIdleConnsPerHost: 10,
+				IdleConnTimeout:     90 * time.Second,
+			},
+		}
+	}
+	return fantasy.NewAgentTool(
+		DownloadToolName,
+		string(downloadDescription),
+		func(ctx context.Context, params DownloadParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.URL == "" {
+				return fantasy.NewTextErrorResponse("URL parameter is required"), nil
+			}
+
+			if params.FilePath == "" {
+				return fantasy.NewTextErrorResponse("file_path parameter is required"), nil
+			}
+
+			if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
+				return fantasy.NewTextErrorResponse("URL must start with http:// or https://"), nil
+			}
+
+			filePath := filepathext.SmartJoin(workingDir, params.FilePath)
+			relPath, _ := filepath.Rel(workingDir, filePath)
+			relPath = filepath.ToSlash(cmp.Or(relPath, filePath))
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for downloading files")
+			}
+
+			p := permissions.Request(
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        filePath,
+					ToolName:    DownloadToolName,
+					Action:      "download",
+					Description: fmt.Sprintf("Download file from URL: %s to %s", params.URL, filePath),
+					Params:      DownloadPermissionsParams(params),
+				},
+			)
+
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			// Handle timeout with context
+			requestCtx := ctx
+			if params.Timeout > 0 {
+				maxTimeout := 600 // 10 minutes
+				if params.Timeout > maxTimeout {
+					params.Timeout = maxTimeout
+				}
+				var cancel context.CancelFunc
+				requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
+				defer cancel()
+			}
+
+			req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
+			}
+
+			req.Header.Set("User-Agent", "crush/1.0")
+
+			resp, err := client.Do(req)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to download from URL: %w", err)
+			}
+			defer resp.Body.Close()
+
+			if resp.StatusCode != http.StatusOK {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
+			}
+
+			// Check content length if available
+			maxSize := int64(100 * 1024 * 1024) // 100MB
+			if resp.ContentLength > maxSize {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("File too large: %d bytes (max %d bytes)", resp.ContentLength, maxSize)), nil
+			}
+
+			// Create parent directories if they don't exist
+			if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
+			}
+
+			// Create the output file
+			outFile, err := os.Create(filePath)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to create output file: %w", err)
+			}
+			defer outFile.Close()
+
+			// Copy data with size limit
+			limitedReader := io.LimitReader(resp.Body, maxSize)
+			bytesWritten, err := io.Copy(outFile, limitedReader)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+			}
+
+			// Check if we hit the size limit
+			if bytesWritten == maxSize {
+				// Clean up the file since it might be incomplete
+				os.Remove(filePath)
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("File too large: exceeded %d bytes limit", maxSize)), nil
+			}
+
+			contentType := resp.Header.Get("Content-Type")
+			responseMsg := fmt.Sprintf("Successfully downloaded %d bytes to %s", bytesWritten, relPath)
+			if contentType != "" {
+				responseMsg += fmt.Sprintf(" (Content-Type: %s)", contentType)
+			}
+
+			return fantasy.NewTextResponse(responseMsg), nil
+		})
+}
diff --git a/internal/agent/tools/download.md b/internal/agent/tools/download.md
new file mode 100644
index 00000000..38224e5f
--- /dev/null
+++ b/internal/agent/tools/download.md
@@ -0,0 +1,28 @@
+Downloads binary data from URL and saves to local file.
+
+<usage>
+- Provide URL to download from
+- Specify local file path where content should be saved
+- Optional timeout for request
+</usage>
+
+<features>
+- Downloads any file type (binary or text)
+- Auto-creates parent directories if missing
+- Handles large files efficiently with streaming
+- Sets reasonable timeouts to prevent hanging
+- Validates input parameters before requests
+</features>
+
+<limitations>
+- Max file size: 100MB
+- Only supports HTTP and HTTPS protocols
+- Cannot handle authentication or cookies
+- Some websites may block automated requests
+- Will overwrite existing files without warning
+</limitations>
+
+<tips>
+- Use absolute paths or paths relative to working directory
+- Set appropriate timeouts for large files or slow connections
+</tips>
diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go
new file mode 100644
index 00000000..7012afc8
--- /dev/null
+++ b/internal/agent/tools/edit.go
@@ -0,0 +1,448 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/history"
+
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+type EditParams struct {
+	FilePath   string `json:"file_path" description:"The absolute path to the file to modify"`
+	OldString  string `json:"old_string" description:"The text to replace"`
+	NewString  string `json:"new_string" description:"The text to replace it with"`
+	ReplaceAll bool   `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)"`
+}
+
+type EditPermissionsParams struct {
+	FilePath   string `json:"file_path"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+type EditResponseMetadata struct {
+	Additions  int    `json:"additions"`
+	Removals   int    `json:"removals"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+const EditToolName = "edit"
+
+//go:embed edit.md
+var editDescription []byte
+
+type editContext struct {
+	ctx         context.Context
+	permissions permission.Service
+	files       history.Service
+	workingDir  string
+}
+
+func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		EditToolName,
+		string(editDescription),
+		func(ctx context.Context, params EditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.FilePath == "" {
+				return fantasy.NewTextErrorResponse("file_path is required"), nil
+			}
+
+			params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
+
+			var response fantasy.ToolResponse
+			var err error
+
+			editCtx := editContext{ctx, permissions, files, workingDir}
+
+			if params.OldString == "" {
+				response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
+				if err != nil {
+					return response, err
+				}
+			}
+
+			if params.NewString == "" {
+				response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
+				if err != nil {
+					return response, err
+				}
+			}
+
+			response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
+			if err != nil {
+				return response, err
+			}
+			if response.IsError {
+				// Return early if there was an error during content replacement
+				// This prevents unnecessary LSP diagnostics processing
+				return response, nil
+			}
+
+			notifyLSPs(ctx, lspClients, params.FilePath)
+
+			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
+			text += getDiagnostics(params.FilePath, lspClients)
+			response.Content = text
+			return response, nil
+		})
+}
+
+func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+	fileInfo, err := os.Stat(filePath)
+	if err == nil {
+		if fileInfo.IsDir() {
+			return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
+		}
+		return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
+	} else if !os.IsNotExist(err) {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+	}
+
+	dir := filepath.Dir(filePath)
+	if err = os.MkdirAll(dir, 0o755); err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
+	}
+
+	sessionID := GetSessionFromContext(edit.ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
+	}
+
+	_, additions, removals := diff.GenerateDiff(
+		"",
+		content,
+		strings.TrimPrefix(filePath, edit.workingDir),
+	)
+	p := edit.permissions.Request(
+		permission.CreatePermissionRequest{
+			SessionID:   sessionID,
+			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
+			ToolCallID:  call.ID,
+			ToolName:    EditToolName,
+			Action:      "write",
+			Description: fmt.Sprintf("Create file %s", filePath),
+			Params: EditPermissionsParams{
+				FilePath:   filePath,
+				OldContent: "",
+				NewContent: content,
+			},
+		},
+	)
+	if !p {
+		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+	}
+
+	err = os.WriteFile(filePath, []byte(content), 0o644)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+	}
+
+	// File can't be in the history so we create a new file history
+	_, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
+	if err != nil {
+		// Log error but don't fail the operation
+		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+	}
+
+	// Add the new content to the file history
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
+	if err != nil {
+		// Log error but don't fail the operation
+		slog.Debug("Error creating file history version", "error", err)
+	}
+
+	recordFileWrite(filePath)
+	recordFileRead(filePath)
+
+	return fantasy.WithResponseMetadata(
+		fantasy.NewTextResponse("File created: "+filePath),
+		EditResponseMetadata{
+			OldContent: "",
+			NewContent: content,
+			Additions:  additions,
+			Removals:   removals,
+		},
+	), nil
+}
+
+func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+	fileInfo, err := os.Stat(filePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
+		}
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+	}
+
+	if fileInfo.IsDir() {
+		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
+	}
+
+	if getLastReadTime(filePath).IsZero() {
+		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
+	}
+
+	modTime := fileInfo.ModTime()
+	lastRead := getLastReadTime(filePath)
+	if modTime.After(lastRead) {
+		return fantasy.NewTextErrorResponse(
+			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
+				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
+			)), nil
+	}
+
+	content, err := os.ReadFile(filePath)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
+	}
+
+	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
+
+	var newContent string
+	var deletionCount int
+
+	if replaceAll {
+		newContent = strings.ReplaceAll(oldContent, oldString, "")
+		deletionCount = strings.Count(oldContent, oldString)
+		if deletionCount == 0 {
+			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
+		}
+	} else {
+		index := strings.Index(oldContent, oldString)
+		if index == -1 {
+			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
+		}
+
+		lastIndex := strings.LastIndex(oldContent, oldString)
+		if index != lastIndex {
+			return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
+		}
+
+		newContent = oldContent[:index] + oldContent[index+len(oldString):]
+		deletionCount = 1
+	}
+
+	sessionID := GetSessionFromContext(edit.ctx)
+
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
+	}
+
+	_, additions, removals := diff.GenerateDiff(
+		oldContent,
+		newContent,
+		strings.TrimPrefix(filePath, edit.workingDir),
+	)
+
+	p := edit.permissions.Request(
+		permission.CreatePermissionRequest{
+			SessionID:   sessionID,
+			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
+			ToolCallID:  call.ID,
+			ToolName:    EditToolName,
+			Action:      "write",
+			Description: fmt.Sprintf("Delete content from file %s", filePath),
+			Params: EditPermissionsParams{
+				FilePath:   filePath,
+				OldContent: oldContent,
+				NewContent: newContent,
+			},
+		},
+	)
+	if !p {
+		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+	}
+
+	if isCrlf {
+		newContent, _ = fsext.ToWindowsLineEndings(newContent)
+	}
+
+	err = os.WriteFile(filePath, []byte(newContent), 0o644)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+	}
+
+	// Check if file exists in history
+	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
+	if err != nil {
+		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
+		if err != nil {
+			// Log error but don't fail the operation
+			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+		}
+	}
+	if file.Content != oldContent {
+		// User Manually changed the content store an intermediate version
+		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
+		if err != nil {
+			slog.Debug("Error creating file history version", "error", err)
+		}
+	}
+	// Store the new version
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, "")
+	if err != nil {
+		slog.Debug("Error creating file history version", "error", err)
+	}
+
+	recordFileWrite(filePath)
+	recordFileRead(filePath)
+
+	return fantasy.WithResponseMetadata(
+		fantasy.NewTextResponse("Content deleted from file: "+filePath),
+		EditResponseMetadata{
+			OldContent: oldContent,
+			NewContent: newContent,
+			Additions:  additions,
+			Removals:   removals,
+		},
+	), nil
+}
+
+func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+	fileInfo, err := os.Stat(filePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
+		}
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+	}
+
+	if fileInfo.IsDir() {
+		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
+	}
+
+	if getLastReadTime(filePath).IsZero() {
+		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
+	}
+
+	modTime := fileInfo.ModTime()
+	lastRead := getLastReadTime(filePath)
+	if modTime.After(lastRead) {
+		return fantasy.NewTextErrorResponse(
+			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
+				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
+			)), nil
+	}
+
+	content, err := os.ReadFile(filePath)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
+	}
+
+	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
+
+	var newContent string
+	var replacementCount int
+
+	if replaceAll {
+		newContent = strings.ReplaceAll(oldContent, oldString, newString)
+		replacementCount = strings.Count(oldContent, oldString)
+		if replacementCount == 0 {
+			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
+		}
+	} else {
+		index := strings.Index(oldContent, oldString)
+		if index == -1 {
+			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
+		}
+
+		lastIndex := strings.LastIndex(oldContent, oldString)
+		if index != lastIndex {
+			return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
+		}
+
+		newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
+		replacementCount = 1
+	}
+
+	if oldContent == newContent {
+		return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
+	}
+	sessionID := GetSessionFromContext(edit.ctx)
+
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
+	}
+	_, additions, removals := diff.GenerateDiff(
+		oldContent,
+		newContent,
+		strings.TrimPrefix(filePath, edit.workingDir),
+	)
+
+	p := edit.permissions.Request(
+		permission.CreatePermissionRequest{
+			SessionID:   sessionID,
+			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
+			ToolCallID:  call.ID,
+			ToolName:    EditToolName,
+			Action:      "write",
+			Description: fmt.Sprintf("Replace content in file %s", filePath),
+			Params: EditPermissionsParams{
+				FilePath:   filePath,
+				OldContent: oldContent,
+				NewContent: newContent,
+			},
+		},
+	)
+	if !p {
+		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+	}
+
+	if isCrlf {
+		newContent, _ = fsext.ToWindowsLineEndings(newContent)
+	}
+
+	err = os.WriteFile(filePath, []byte(newContent), 0o644)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+	}
+
+	// Check if file exists in history
+	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
+	if err != nil {
+		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
+		if err != nil {
+			// Log error but don't fail the operation
+			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+		}
+	}
+	if file.Content != oldContent {
+		// User Manually changed the content store an intermediate version
+		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
+		if err != nil {
+			slog.Debug("Error creating file history version", "error", err)
+		}
+	}
+	// Store the new version
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
+	if err != nil {
+		slog.Debug("Error creating file history version", "error", err)
+	}
+
+	recordFileWrite(filePath)
+	recordFileRead(filePath)
+
+	return fantasy.WithResponseMetadata(
+		fantasy.NewTextResponse("Content replaced in file: "+filePath),
+		EditResponseMetadata{
+			OldContent: oldContent,
+			NewContent: newContent,
+			Additions:  additions,
+			Removals:   removals,
+		}), nil
+}
diff --git a/internal/agent/tools/edit.md b/internal/agent/tools/edit.md
new file mode 100644
index 00000000..2d2d9b08
--- /dev/null
+++ b/internal/agent/tools/edit.md
@@ -0,0 +1,147 @@
+Edits files by replacing text, creating new files, or deleting content. For moving/renaming use Bash 'mv'. For large edits use Write tool.
+
+<prerequisites>
+1. Use View tool to understand file contents and context
+2. For new files: Use LS tool to verify parent directory exists
+3. **CRITICAL**: Note exact whitespace, indentation, and formatting from View output
+</prerequisites>
+
+<parameters>
+1. file_path: Absolute path to file (required)
+2. old_string: Text to replace (must match exactly including whitespace/indentation)
+3. new_string: Replacement text
+4. replace_all: Replace all occurrences (default false)
+</parameters>
+
+<special_cases>
+
+- Create file: provide file_path + new_string, leave old_string empty
+- Delete content: provide file_path + old_string, leave new_string empty
+  </special_cases>
+
+<critical_requirements>
+EXACT MATCHING: The tool is extremely literal. Text must match **EXACTLY**
+
+- Every space and tab character
+- Every blank line
+- Every newline character
+- Indentation level (count the spaces/tabs)
+- Comment spacing (`// comment` vs `//comment`)
+- Brace positioning (`func() {` vs `func(){`)
+
+Common failures:
+
+```
+Expected: "    func foo() {"     (4 spaces)
+Provided: "  func foo() {"       (2 spaces) ❌ FAILS
+
+Expected: "}\n\nfunc bar() {"    (2 newlines)
+Provided: "}\nfunc bar() {"      (1 newline) ❌ FAILS
+
+Expected: "// Comment"           (space after //)
+Provided: "//Comment"            (no space) ❌ FAILS
+```
+
+UNIQUENESS (when replace_all=false): old_string MUST uniquely identify target instance
+
+- Include 3-5 lines context BEFORE and AFTER change point
+- Include exact whitespace, indentation, surrounding code
+- If text appears multiple times, add more context to make it unique
+
+SINGLE INSTANCE: Tool changes ONE instance when replace_all=false
+
+- For multiple instances: set replace_all=true OR make separate calls with unique context
+- Plan calls carefully to avoid conflicts
+
+VERIFICATION BEFORE USING: Before every edit
+
+1. View the file and locate exact target location
+2. Check how many instances of target text exist
+3. Copy the EXACT text including all whitespace
+4. Verify you have enough context for unique identification
+5. Double-check indentation matches (count spaces/tabs)
+6. Plan separate calls or use replace_all for multiple changes
+   </critical_requirements>
+
+<warnings>
+Tool fails if:
+- old_string matches multiple locations and replace_all=false
+- old_string doesn't match exactly (including whitespace)
+- Insufficient context causes wrong instance change
+- Indentation is off by even one space
+- Missing or extra blank lines
+- Wrong tabs vs spaces
+</warnings>
+
+<recovery_steps>
+If you get "old_string not found in file":
+
+1. **View the file again** at the specific location
+2. **Copy more context** - include entire function if needed
+3. **Check whitespace**:
+   - Count indentation spaces/tabs
+   - Look for blank lines
+   - Check for trailing spaces
+4. **Verify character-by-character** that your old_string matches
+5. **Never guess** - always View the file to get exact text
+   </recovery_steps>
+
+<best_practices>
+
+- Ensure edits result in correct, idiomatic code
+- Don't leave code in broken state
+- Use absolute file paths (starting with /)
+- Use forward slashes (/) for cross-platform compatibility
+- Multiple edits to same file: send all in single message with multiple tool calls
+- **When in doubt, include MORE context rather than less**
+- Match the existing code style exactly (spaces, tabs, blank lines)
+  </best_practices>
+
+<whitespace_checklist>
+Before submitting an edit, verify:
+
+- [ ] Viewed the file first
+- [ ] Counted indentation spaces/tabs
+- [ ] Included blank lines if they exist
+- [ ] Matched brace/bracket positioning
+- [ ] Included 3-5 lines of surrounding context
+- [ ] Verified text appears exactly once (or using replace_all)
+- [ ] Copied text character-for-character, not approximated
+      </whitespace_checklist>
+
+<examples>
+✅ Correct: Exact match with context
+
+```
+old_string: "func ProcessData(input string) error {\n    if input == \"\" {\n        return errors.New(\"empty input\")\n    }\n    return nil\n}"
+
+new_string: "func ProcessData(input string) error {\n    if input == \"\" {\n        return errors.New(\"empty input\")\n    }\n    // New validation\n    if len(input) > 1000 {\n        return errors.New(\"input too long\")\n    }\n    return nil\n}"
+```
+
+❌ Incorrect: Not enough context
+
+```
+old_string: "return nil"  // Appears many times!
+```
+
+❌ Incorrect: Wrong indentation
+
+```
+old_string: "  if input == \"\" {"  // 2 spaces
+// But file actually has:        "    if input == \"\" {"  // 4 spaces
+```
+
+✅ Correct: Including context to make unique
+
+```
+old_string: "func ProcessData(input string) error {\n    if input == \"\" {\n        return errors.New(\"empty input\")\n    }\n    return nil"
+```
+
+</examples>
+
+<windows_notes>
+
+- Forward slashes work throughout (C:/path/file)
+- File permissions handled automatically
+- Line endings converted automatically (\n ↔ \r\n)
+  </windows_notes>
diff --git a/internal/agent/tools/fetch.go b/internal/agent/tools/fetch.go
new file mode 100644
index 00000000..1ae1d6b9
--- /dev/null
+++ b/internal/agent/tools/fetch.go
@@ -0,0 +1,187 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+	"unicode/utf8"
+
+	"charm.land/fantasy"
+	md "github.com/JohannesKaufmann/html-to-markdown"
+	"github.com/PuerkitoBio/goquery"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+const FetchToolName = "fetch"
+
+//go:embed fetch.md
+var fetchDescription []byte
+
+func NewFetchTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
+	if client == nil {
+		client = &http.Client{
+			Timeout: 30 * time.Second,
+			Transport: &http.Transport{
+				MaxIdleConns:        100,
+				MaxIdleConnsPerHost: 10,
+				IdleConnTimeout:     90 * time.Second,
+			},
+		}
+	}
+
+	return fantasy.NewAgentTool(
+		FetchToolName,
+		string(fetchDescription),
+		func(ctx context.Context, params FetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.URL == "" {
+				return fantasy.NewTextErrorResponse("URL parameter is required"), nil
+			}
+
+			format := strings.ToLower(params.Format)
+			if format != "text" && format != "markdown" && format != "html" {
+				return fantasy.NewTextErrorResponse("Format must be one of: text, markdown, html"), nil
+			}
+
+			if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
+				return fantasy.NewTextErrorResponse("URL must start with http:// or https://"), nil
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
+			}
+
+			p := permissions.Request(
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        workingDir,
+					ToolCallID:  call.ID,
+					ToolName:    FetchToolName,
+					Action:      "fetch",
+					Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
+					Params:      FetchPermissionsParams(params),
+				},
+			)
+
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			// Handle timeout with context
+			requestCtx := ctx
+			if params.Timeout > 0 {
+				maxTimeout := 120 // 2 minutes
+				if params.Timeout > maxTimeout {
+					params.Timeout = maxTimeout
+				}
+				var cancel context.CancelFunc
+				requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
+				defer cancel()
+			}
+
+			req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
+			}
+
+			req.Header.Set("User-Agent", "crush/1.0")
+
+			resp, err := client.Do(req)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
+			}
+			defer resp.Body.Close()
+
+			if resp.StatusCode != http.StatusOK {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
+			}
+
+			maxSize := int64(5 * 1024 * 1024) // 5MB
+			body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
+			if err != nil {
+				return fantasy.NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
+			}
+
+			content := string(body)
+
+			isValidUt8 := utf8.ValidString(content)
+			if !isValidUt8 {
+				return fantasy.NewTextErrorResponse("Response content is not valid UTF-8"), nil
+			}
+			contentType := resp.Header.Get("Content-Type")
+
+			switch format {
+			case "text":
+				if strings.Contains(contentType, "text/html") {
+					text, err := extractTextFromHTML(content)
+					if err != nil {
+						return fantasy.NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil
+					}
+					content = text
+				}
+
+			case "markdown":
+				if strings.Contains(contentType, "text/html") {
+					markdown, err := convertHTMLToMarkdown(content)
+					if err != nil {
+						return fantasy.NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil
+					}
+					content = markdown
+				}
+
+				content = "```\n" + content + "\n```"
+
+			case "html":
+				// return only the body of the HTML document
+				if strings.Contains(contentType, "text/html") {
+					doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
+					if err != nil {
+						return fantasy.NewTextErrorResponse("Failed to parse HTML: " + err.Error()), nil
+					}
+					body, err := doc.Find("body").Html()
+					if err != nil {
+						return fantasy.NewTextErrorResponse("Failed to extract body from HTML: " + err.Error()), nil
+					}
+					if body == "" {
+						return fantasy.NewTextErrorResponse("No body content found in HTML"), nil
+					}
+					content = "<html>\n<body>\n" + body + "\n</body>\n</html>"
+				}
+			}
+			// calculate byte size of content
+			contentSize := int64(len(content))
+			if contentSize > MaxReadSize {
+				content = content[:MaxReadSize]
+				content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxReadSize)
+			}
+
+			return fantasy.NewTextResponse(content), nil
+		})
+}
+
+func extractTextFromHTML(html string) (string, error) {
+	doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
+	if err != nil {
+		return "", err
+	}
+
+	text := doc.Find("body").Text()
+	text = strings.Join(strings.Fields(text), " ")
+
+	return text, nil
+}
+
+func convertHTMLToMarkdown(html string) (string, error) {
+	converter := md.NewConverter("", true, nil)
+
+	markdown, err := converter.ConvertString(html)
+	if err != nil {
+		return "", err
+	}
+
+	return markdown, nil
+}
diff --git a/internal/agent/tools/fetch.md b/internal/agent/tools/fetch.md
new file mode 100644
index 00000000..af96a036
--- /dev/null
+++ b/internal/agent/tools/fetch.md
@@ -0,0 +1,45 @@
+Fetches raw content from URL and returns it in specified format without any AI processing.
+
+<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
+- Specify desired output format (text, markdown, or html)
+- Optional timeout for request
+</usage>
+
+<features>
+- Supports three output formats: text, markdown, html
+- Auto-handles HTTP redirects
+- Fast and lightweight - no AI processing
+- Sets reasonable timeouts to prevent hanging
+- Validates input parameters before requests
+</features>
+
+<limitations>
+- Max response size: 5MB
+- Only supports HTTP and HTTPS protocols
+- Cannot handle authentication or cookies
+- Some websites may block automated requests
+- Returns raw content only - no analysis or extraction
+</limitations>
+
+<tips>
+- Use text format for plain text content or simple API responses
+- Use markdown format for content that should be rendered with formatting
+- Use html format when you need raw HTML structure
+- Set appropriate timeouts for potentially slow websites
+- If the user asks to analyze or extract from a page, use agentic_fetch instead
+</tips>
diff --git a/internal/agent/tools/fetch_helpers.go b/internal/agent/tools/fetch_helpers.go
new file mode 100644
index 00000000..35bf0e0d
--- /dev/null
+++ b/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
+}
diff --git a/internal/agent/tools/fetch_types.go b/internal/agent/tools/fetch_types.go
new file mode 100644
index 00000000..54754c28
--- /dev/null
+++ b/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"`
+}
diff --git a/internal/llm/tools/file.go b/internal/agent/tools/file.go
similarity index 100%
rename from internal/llm/tools/file.go
rename to internal/agent/tools/file.go
diff --git a/internal/agent/tools/glob.go b/internal/agent/tools/glob.go
new file mode 100644
index 00000000..3f56d9dc
--- /dev/null
+++ b/internal/agent/tools/glob.go
@@ -0,0 +1,125 @@
+package tools
+
+import (
+	"bytes"
+	"context"
+	_ "embed"
+	"fmt"
+	"log/slog"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/fsext"
+)
+
+const GlobToolName = "glob"
+
+//go:embed glob.md
+var globDescription []byte
+
+type GlobParams struct {
+	Pattern string `json:"pattern" description:"The glob pattern to match files against"`
+	Path    string `json:"path,omitempty" description:"The directory to search in. Defaults to the current working directory."`
+}
+
+type GlobResponseMetadata struct {
+	NumberOfFiles int  `json:"number_of_files"`
+	Truncated     bool `json:"truncated"`
+}
+
+func NewGlobTool(workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		GlobToolName,
+		string(globDescription),
+		func(ctx context.Context, params GlobParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.Pattern == "" {
+				return fantasy.NewTextErrorResponse("pattern is required"), nil
+			}
+
+			searchPath := params.Path
+			if searchPath == "" {
+				searchPath = workingDir
+			}
+
+			files, truncated, err := globFiles(ctx, params.Pattern, searchPath, 100)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error finding files: %w", err)
+			}
+
+			var output string
+			if len(files) == 0 {
+				output = "No files found"
+			} else {
+				normalizeFilePaths(files)
+				output = strings.Join(files, "\n")
+				if truncated {
+					output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
+				}
+			}
+
+			return fantasy.WithResponseMetadata(
+				fantasy.NewTextResponse(output),
+				GlobResponseMetadata{
+					NumberOfFiles: len(files),
+					Truncated:     truncated,
+				},
+			), nil
+		})
+}
+
+func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
+	cmdRg := getRgCmd(ctx, pattern)
+	if cmdRg != nil {
+		cmdRg.Dir = searchPath
+		matches, err := runRipgrep(cmdRg, searchPath, limit)
+		if err == nil {
+			return matches, len(matches) >= limit && limit > 0, nil
+		}
+		slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
+	}
+
+	return fsext.GlobWithDoubleStar(pattern, searchPath, limit)
+}
+
+func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
+	}
+
+	var matches []string
+	for p := range bytes.SplitSeq(out, []byte{0}) {
+		if len(p) == 0 {
+			continue
+		}
+		absPath := string(p)
+		if !filepath.IsAbs(absPath) {
+			absPath = filepath.Join(searchRoot, absPath)
+		}
+		if fsext.SkipHidden(absPath) {
+			continue
+		}
+		matches = append(matches, absPath)
+	}
+
+	sort.SliceStable(matches, func(i, j int) bool {
+		return len(matches[i]) < len(matches[j])
+	})
+
+	if limit > 0 && len(matches) > limit {
+		matches = matches[:limit]
+	}
+	return matches, nil
+}
+
+func normalizeFilePaths(paths []string) {
+	for i, p := range paths {
+		paths[i] = filepath.ToSlash(p)
+	}
+}
diff --git a/internal/agent/tools/glob.md b/internal/agent/tools/glob.md
new file mode 100644
index 00000000..bce7223c
--- /dev/null
+++ b/internal/agent/tools/glob.md
@@ -0,0 +1,40 @@
+Fast file pattern matching tool that finds files by name/pattern, returning paths sorted by modification time (newest first).
+
+<usage>
+- Provide glob pattern to match against file paths
+- Optional starting directory (defaults to current working directory)
+- Results sorted with most recently modified files first
+</usage>
+
+<pattern_syntax>
+- '\*' matches any sequence of non-separator characters
+- '\*\*' matches any sequence including separators
+- '?' matches any single non-separator character
+- '[...]' matches any character in brackets
+- '[!...]' matches any character not in brackets
+</pattern_syntax>
+
+<examples>
+- '*.js' - JavaScript files in current directory
+- '**/*.js' - JavaScript files in any subdirectory
+- 'src/**/*.{ts,tsx}' - TypeScript files in src directory
+- '*.{html,css,js}' - HTML, CSS, and JS files
+</examples>
+
+<limitations>
+- Results limited to 100 files (newest first)
+- Does not search file contents (use Grep for that)
+- Hidden files (starting with '.') skipped
+</limitations>
+
+<cross_platform>
+- Path separators handled automatically (/ and \ work)
+- Uses ripgrep (rg) if available, otherwise Go implementation
+- Patterns should use forward slashes (/) for compatibility
+</cross_platform>
+
+<tips>
+- Combine with Grep: find files with Glob, search contents with Grep
+- For iterative exploration requiring multiple searches, consider Agent tool
+- Check if results truncated and refine pattern if needed
+</tips>
diff --git a/internal/llm/tools/grep.go b/internal/agent/tools/grep.go
similarity index 72%
rename from internal/llm/tools/grep.go
rename to internal/agent/tools/grep.go
index ed844b6c..3111a3f5 100644
--- a/internal/llm/tools/grep.go
+++ b/internal/agent/tools/grep.go
@@ -18,6 +18,7 @@ import (
 	"sync"
 	"time"
 
+	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/fsext"
 )
 
@@ -72,10 +73,10 @@ var (
 )
 
 type GrepParams struct {
-	Pattern     string `json:"pattern"`
-	Path        string `json:"path"`
-	Include     string `json:"include"`
-	LiteralText bool   `json:"literal_text"`
+	Pattern     string `json:"pattern" description:"The regex pattern to search for in file contents"`
+	Path        string `json:"path,omitempty" description:"The directory to search in. Defaults to the current working directory."`
+	Include     string `json:"include,omitempty" description:"File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")"`
+	LiteralText bool   `json:"literal_text,omitempty" description:"If true, the pattern will be treated as literal text with special regex characters escaped. Default is false."`
 }
 
 type grepMatch struct {
@@ -91,51 +92,14 @@ type GrepResponseMetadata struct {
 	Truncated       bool `json:"truncated"`
 }
 
-type grepTool struct {
-	workingDir string
-}
-
-const GrepToolName = "grep"
+const (
+	GrepToolName        = "grep"
+	maxGrepContentWidth = 500
+)
 
 //go:embed grep.md
 var grepDescription []byte
 
-func NewGrepTool(workingDir string) BaseTool {
-	return &grepTool{
-		workingDir: workingDir,
-	}
-}
-
-func (g *grepTool) Name() string {
-	return GrepToolName
-}
-
-func (g *grepTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        GrepToolName,
-		Description: string(grepDescription),
-		Parameters: map[string]any{
-			"pattern": map[string]any{
-				"type":        "string",
-				"description": "The regex pattern to search for in file contents",
-			},
-			"path": map[string]any{
-				"type":        "string",
-				"description": "The directory to search in. Defaults to the current working directory.",
-			},
-			"include": map[string]any{
-				"type":        "string",
-				"description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
-			},
-			"literal_text": map[string]any{
-				"type":        "boolean",
-				"description": "If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.",
-			},
-		},
-		Required: []string{"pattern"},
-	}
-}
-
 // escapeRegexPattern escapes special regex characters so they're treated as literal characters
 func escapeRegexPattern(pattern string) string {
 	specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
@@ -148,70 +112,74 @@ func escapeRegexPattern(pattern string) string {
 	return escaped
 }
 
-func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params GrepParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	if params.Pattern == "" {
-		return NewTextErrorResponse("pattern is required"), nil
-	}
+func NewGrepTool(workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		GrepToolName,
+		string(grepDescription),
+		func(ctx context.Context, params GrepParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.Pattern == "" {
+				return fantasy.NewTextErrorResponse("pattern is required"), nil
+			}
 
-	// If literal_text is true, escape the pattern
-	searchPattern := params.Pattern
-	if params.LiteralText {
-		searchPattern = escapeRegexPattern(params.Pattern)
-	}
+			// If literal_text is true, escape the pattern
+			searchPattern := params.Pattern
+			if params.LiteralText {
+				searchPattern = escapeRegexPattern(params.Pattern)
+			}
 
-	searchPath := params.Path
-	if searchPath == "" {
-		searchPath = g.workingDir
-	}
+			searchPath := params.Path
+			if searchPath == "" {
+				searchPath = workingDir
+			}
 
-	matches, truncated, err := searchFiles(ctx, searchPattern, searchPath, params.Include, 100)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error searching files: %w", err)
-	}
+			matches, truncated, err := searchFiles(ctx, searchPattern, searchPath, params.Include, 100)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("error searching files: %v", err)), nil
+			}
 
-	var output strings.Builder
-	if len(matches) == 0 {
-		output.WriteString("No files found")
-	} else {
-		fmt.Fprintf(&output, "Found %d matches\n", len(matches))
-
-		currentFile := ""
-		for _, match := range matches {
-			if currentFile != match.path {
-				if currentFile != "" {
-					output.WriteString("\n")
+			var output strings.Builder
+			if len(matches) == 0 {
+				output.WriteString("No files found")
+			} else {
+				fmt.Fprintf(&output, "Found %d matches\n", len(matches))
+
+				currentFile := ""
+				for _, match := range matches {
+					if currentFile != match.path {
+						if currentFile != "" {
+							output.WriteString("\n")
+						}
+						currentFile = match.path
+						fmt.Fprintf(&output, "%s:\n", filepath.ToSlash(match.path))
+					}
+					if match.lineNum > 0 {
+						lineText := match.lineText
+						if len(lineText) > maxGrepContentWidth {
+							lineText = lineText[:maxGrepContentWidth] + "..."
+						}
+						if match.charNum > 0 {
+							fmt.Fprintf(&output, "  Line %d, Char %d: %s\n", match.lineNum, match.charNum, lineText)
+						} else {
+							fmt.Fprintf(&output, "  Line %d: %s\n", match.lineNum, lineText)
+						}
+					} else {
+						fmt.Fprintf(&output, "  %s\n", match.path)
+					}
 				}
-				currentFile = match.path
-				fmt.Fprintf(&output, "%s:\n", match.path)
-			}
-			if match.lineNum > 0 {
-				if match.charNum > 0 {
-					fmt.Fprintf(&output, "  Line %d, Char %d: %s\n", match.lineNum, match.charNum, match.lineText)
-				} else {
-					fmt.Fprintf(&output, "  Line %d: %s\n", match.lineNum, match.lineText)
+
+				if truncated {
+					output.WriteString("\n(Results are truncated. Consider using a more specific path or pattern.)")
 				}
-			} else {
-				fmt.Fprintf(&output, "  %s\n", match.path)
 			}
-		}
-
-		if truncated {
-			output.WriteString("\n(Results are truncated. Consider using a more specific path or pattern.)")
-		}
-	}
 
-	return WithResponseMetadata(
-		NewTextResponse(output.String()),
-		GrepResponseMetadata{
-			NumberOfMatches: len(matches),
-			Truncated:       truncated,
-		},
-	), nil
+			return fantasy.WithResponseMetadata(
+				fantasy.NewTextResponse(output.String()),
+				GrepResponseMetadata{
+					NumberOfMatches: len(matches),
+					Truncated:       truncated,
+				},
+			), nil
+		})
 }
 
 func searchFiles(ctx context.Context, pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
diff --git a/internal/agent/tools/grep.md b/internal/agent/tools/grep.md
new file mode 100644
index 00000000..2fe104ba
--- /dev/null
+++ b/internal/agent/tools/grep.md
@@ -0,0 +1,49 @@
+Fast content search tool that finds files containing specific text/patterns, returning matching paths sorted by modification time (newest first).
+
+<usage>
+- Provide regex pattern to search within file contents
+- Set literal_text=true for exact text with special characters (recommended for non-regex users)
+- Optional starting directory (defaults to current working directory)
+- Optional include pattern to filter which files to search
+- Results sorted with most recently modified files first
+</usage>
+
+<regex_syntax>
+When literal_text=false (supports standard regex):
+
+- 'function' searches for literal text "function"
+- 'log\..\*Error' finds text starting with "log." and ending with "Error"
+- 'import\s+.\*\s+from' finds import statements in JavaScript/TypeScript
+</regex_syntax>
+
+<include_patterns>
+- '\*.js' - Only search JavaScript files
+- '\*.{ts,tsx}' - Only search TypeScript files
+- '\*.go' - Only search Go files
+</include_patterns>
+
+<limitations>
+- Results limited to 100 files (newest first)
+- Performance depends on number of files searched
+- Very large binary files may be skipped
+- Hidden files (starting with '.') skipped
+</limitations>
+
+<ignore_support>
+- Respects .gitignore patterns to skip ignored files/directories
+- Respects .crushignore patterns for additional ignore rules
+- Both ignore files auto-detected in search root directory
+</ignore_support>
+
+<cross_platform>
+- Uses ripgrep (rg) if available for better performance
+- Falls back to Go implementation if ripgrep unavailable
+- File paths normalized automatically for compatibility
+</cross_platform>
+
+<tips>
+- For faster searches: use Glob to find relevant files first, then Grep
+- For iterative exploration requiring multiple searches, consider Agent tool
+- Check if results truncated and refine search pattern if needed
+- Use literal_text=true for exact text with special characters (dots, parentheses, etc.)
+</tips>
diff --git a/internal/llm/tools/ls.go b/internal/agent/tools/ls.go
similarity index 55%
rename from internal/llm/tools/ls.go
rename to internal/agent/tools/ls.go
index af25259d..7725910c 100644
--- a/internal/llm/tools/ls.go
+++ b/internal/agent/tools/ls.go
@@ -4,21 +4,22 @@ import (
 	"cmp"
 	"context"
 	_ "embed"
-	"encoding/json"
 	"fmt"
 	"os"
 	"path/filepath"
 	"strings"
 
+	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/filepathext"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/permission"
 )
 
 type LSParams struct {
-	Path   string   `json:"path"`
-	Ignore []string `json:"ignore"`
-	Depth  int      `json:"depth"`
+	Path   string   `json:"path,omitempty" description:"The path to the directory to list (defaults to current working directory)"`
+	Ignore []string `json:"ignore,omitempty" description:"List of glob patterns to ignore"`
+	Depth  int      `json:"depth,omitempty" description:"The maximum depth to traverse"`
 }
 
 type LSPermissionsParams struct {
@@ -39,11 +40,6 @@ type LSResponseMetadata struct {
 	Truncated     bool `json:"truncated"`
 }
 
-type lsTool struct {
-	workingDir  string
-	permissions permission.Service
-}
-
 const (
 	LSToolName = "ls"
 	maxLSFiles = 1000
@@ -52,111 +48,72 @@ const (
 //go:embed ls.md
 var lsDescription []byte
 
-func NewLsTool(permissions permission.Service, workingDir string) BaseTool {
-	return &lsTool{
-		workingDir:  workingDir,
-		permissions: permissions,
-	}
-}
-
-func (l *lsTool) Name() string {
-	return LSToolName
-}
-
-func (l *lsTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        LSToolName,
-		Description: string(lsDescription),
-		Parameters: map[string]any{
-			"path": map[string]any{
-				"type":        "string",
-				"description": "The path to the directory to list (defaults to current working directory)",
-			},
-			"depth": map[string]any{
-				"type":        "integer",
-				"description": "The maximum depth to traverse",
-			},
-			"ignore": map[string]any{
-				"type":        "array",
-				"description": "List of glob patterns to ignore",
-				"items": map[string]any{
-					"type": "string",
-				},
-			},
-		},
-		Required: []string{"path"},
-	}
-}
-
-func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params LSParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	searchPath, err := fsext.Expand(cmp.Or(params.Path, l.workingDir))
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
-	}
+func NewLsTool(permissions permission.Service, workingDir string, lsConfig config.ToolLs) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		LSToolName,
+		string(lsDescription),
+		func(ctx context.Context, params LSParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			searchPath, err := fsext.Expand(cmp.Or(params.Path, workingDir))
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("error expanding path: %v", err)), nil
+			}
 
-	if !filepath.IsAbs(searchPath) {
-		searchPath = filepath.Join(l.workingDir, searchPath)
-	}
+			searchPath = filepathext.SmartJoin(workingDir, searchPath)
 
-	// Check if directory is outside working directory and request permission if needed
-	absWorkingDir, err := filepath.Abs(l.workingDir)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
-	}
+			// Check if directory is outside working directory and request permission if needed
+			absWorkingDir, err := filepath.Abs(workingDir)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("error resolving working directory: %v", err)), nil
+			}
 
-	absSearchPath, err := filepath.Abs(searchPath)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error resolving search path: %w", err)
-	}
+			absSearchPath, err := filepath.Abs(searchPath)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("error resolving search path: %v", err)), nil
+			}
 
-	relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
-	if err != nil || strings.HasPrefix(relPath, "..") {
-		// Directory is outside working directory, request permission
-		sessionID, messageID := GetContextValues(ctx)
-		if sessionID == "" || messageID == "" {
-			return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing directories outside working directory")
-		}
+			relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
+			if err != nil || strings.HasPrefix(relPath, "..") {
+				// Directory is outside working directory, request permission
+				sessionID := GetSessionFromContext(ctx)
+				if sessionID == "" {
+					return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory")
+				}
 
-		granted := l.permissions.Request(
-			permission.CreatePermissionRequest{
-				SessionID:   sessionID,
-				Path:        absSearchPath,
-				ToolCallID:  call.ID,
-				ToolName:    LSToolName,
-				Action:      "list",
-				Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
-				Params:      LSPermissionsParams(params),
-			},
-		)
-
-		if !granted {
-			return ToolResponse{}, permission.ErrorPermissionDenied
-		}
-	}
+				granted := permissions.Request(
+					permission.CreatePermissionRequest{
+						SessionID:   sessionID,
+						Path:        absSearchPath,
+						ToolCallID:  call.ID,
+						ToolName:    LSToolName,
+						Action:      "list",
+						Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
+						Params:      LSPermissionsParams(params),
+					},
+				)
+
+				if !granted {
+					return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+				}
+			}
 
-	output, metadata, err := ListDirectoryTree(searchPath, params)
-	if err != nil {
-		return ToolResponse{}, err
-	}
+			output, metadata, err := ListDirectoryTree(searchPath, params, lsConfig)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(err.Error()), err
+			}
 
-	return WithResponseMetadata(
-		NewTextResponse(output),
-		metadata,
-	), nil
+			return fantasy.WithResponseMetadata(
+				fantasy.NewTextResponse(output),
+				metadata,
+			), nil
+		})
 }
 
-func ListDirectoryTree(searchPath string, params LSParams) (string, LSResponseMetadata, error) {
+func ListDirectoryTree(searchPath string, params LSParams, lsConfig config.ToolLs) (string, LSResponseMetadata, error) {
 	if _, err := os.Stat(searchPath); os.IsNotExist(err) {
 		return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
 	}
 
-	ls := config.Get().Tools.Ls
-	depth, limit := ls.Limits()
+	depth, limit := lsConfig.Limits()
 	maxFiles := cmp.Or(limit, maxLSFiles)
 	files, truncated, err := fsext.ListDirectory(
 		searchPath,
@@ -252,9 +209,9 @@ func printTree(tree []*TreeNode, rootPath string) string {
 	var result strings.Builder
 
 	result.WriteString("- ")
-	result.WriteString(rootPath)
+	result.WriteString(filepath.ToSlash(rootPath))
 	if rootPath[len(rootPath)-1] != '/' {
-		result.WriteByte(filepath.Separator)
+		result.WriteByte('/')
 	}
 	result.WriteByte('\n')
 
@@ -270,7 +227,7 @@ func printNode(builder *strings.Builder, node *TreeNode, level int) {
 
 	nodeName := node.Name
 	if node.Type == "directory" {
-		nodeName = nodeName + string(filepath.Separator)
+		nodeName = nodeName + "/"
 	}
 
 	fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
diff --git a/internal/agent/tools/ls.md b/internal/agent/tools/ls.md
new file mode 100644
index 00000000..ba4fd746
--- /dev/null
+++ b/internal/agent/tools/ls.md
@@ -0,0 +1,34 @@
+Shows files and subdirectories in tree structure for exploring project organization.
+
+<usage>
+- Provide path to list (defaults to current working directory)
+- Optional glob patterns to ignore
+- Results displayed in tree structure
+</usage>
+
+<features>
+- Hierarchical view of files and directories
+- Auto-skips hidden files/directories (starting with '.')
+- Skips common system directories like __pycache__
+- Can filter files matching specific patterns
+</features>
+
+<limitations>
+- Results limited to 1000 files
+- Large directories truncated
+- No file sizes or permissions shown
+- Cannot recursively list all directories in large projects
+</limitations>
+
+<cross_platform>
+- Hidden file detection uses Unix convention (files starting with '.')
+- Windows hidden files (with hidden attribute) not auto-skipped
+- Common Windows directories (System32, Program Files) not in default ignore
+- Path separators handled automatically (/ and \ work)
+</cross_platform>
+
+<tips>
+- Use Glob for finding files by name patterns instead of browsing
+- Use Grep for searching file contents
+- Combine with other tools for effective exploration
+</tips>
diff --git a/internal/agent/tools/mcp-tools.go b/internal/agent/tools/mcp-tools.go
new file mode 100644
index 00000000..e75a6ccb
--- /dev/null
+++ b/internal/agent/tools/mcp-tools.go
@@ -0,0 +1,112 @@
+package tools
+
+import (
+	"context"
+	"fmt"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+// 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
+}
+
+// Tool is a tool from a MCP.
+type Tool struct {
+	mcpName         string
+	tool            *mcp.Tool
+	permissions     permission.Service
+	workingDir      string
+	providerOptions fantasy.ProviderOptions
+}
+
+func (m *Tool) SetProviderOptions(opts fantasy.ProviderOptions) {
+	m.providerOptions = opts
+}
+
+func (m *Tool) ProviderOptions() fantasy.ProviderOptions {
+	return m.providerOptions
+}
+
+func (m *Tool) Name() string {
+	return fmt.Sprintf("mcp_%s_%s", m.mcpName, m.tool.Name)
+}
+
+func (m *Tool) MCP() string {
+	return m.mcpName
+}
+
+func (m *Tool) MCPToolName() string {
+	return m.tool.Name
+}
+
+func (m *Tool) Info() fantasy.ToolInfo {
+	parameters := make(map[string]any)
+	required := make([]string, 0)
+
+	if input, ok := m.tool.InputSchema.(map[string]any); ok {
+		if props, ok := input["properties"].(map[string]any); ok {
+			parameters = props
+		}
+		if req, ok := input["required"].([]any); ok {
+			// Convert []any -> []string when elements are strings
+			for _, v := range req {
+				if s, ok := v.(string); ok {
+					required = append(required, s)
+				}
+			}
+		} else if reqStr, ok := input["required"].([]string); ok {
+			// Handle case where it's already []string
+			required = reqStr
+		}
+	}
+
+	return fantasy.ToolInfo{
+		Name:        m.Name(),
+		Description: m.tool.Description,
+		Parameters:  parameters,
+		Required:    required,
+	}
+}
+
+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")
+	}
+	permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
+	p := m.permissions.Request(
+		permission.CreatePermissionRequest{
+			SessionID:   sessionID,
+			ToolCallID:  params.ID,
+			Path:        m.workingDir,
+			ToolName:    m.Info().Name,
+			Action:      "execute",
+			Description: permissionDescription,
+			Params:      params.Input,
+		},
+	)
+	if !p {
+		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+	}
+
+	content, err := mcp.RunTool(ctx, m.mcpName, m.tool.Name, params.Input)
+	if err != nil {
+		return fantasy.NewTextErrorResponse(err.Error()), nil
+	}
+	return fantasy.NewTextResponse(content), nil
+}
diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go
new file mode 100644
index 00000000..cb97c0e4
--- /dev/null
+++ b/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))
+}
diff --git a/internal/agent/tools/mcp/prompts.go b/internal/agent/tools/mcp/prompts.go
new file mode 100644
index 00000000..0bd6e665
--- /dev/null
+++ b/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)
+}
diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go
new file mode 100644
index 00000000..561783e6
--- /dev/null
+++ b/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)
+}
diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go
new file mode 100644
index 00000000..72b7583a
--- /dev/null
+++ b/internal/agent/tools/multiedit.go
@@ -0,0 +1,409 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+type MultiEditOperation struct {
+	OldString  string `json:"old_string" description:"The text to replace"`
+	NewString  string `json:"new_string" description:"The text to replace it with"`
+	ReplaceAll bool   `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)."`
+}
+
+type MultiEditParams struct {
+	FilePath string               `json:"file_path" description:"The absolute path to the file to modify"`
+	Edits    []MultiEditOperation `json:"edits" description:"Array of edit operations to perform sequentially on the file"`
+}
+
+type MultiEditPermissionsParams struct {
+	FilePath   string `json:"file_path"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+type FailedEdit struct {
+	Index int                `json:"index"`
+	Error string             `json:"error"`
+	Edit  MultiEditOperation `json:"edit"`
+}
+
+type MultiEditResponseMetadata struct {
+	Additions    int          `json:"additions"`
+	Removals     int          `json:"removals"`
+	OldContent   string       `json:"old_content,omitempty"`
+	NewContent   string       `json:"new_content,omitempty"`
+	EditsApplied int          `json:"edits_applied"`
+	EditsFailed  []FailedEdit `json:"edits_failed,omitempty"`
+}
+
+const MultiEditToolName = "multiedit"
+
+//go:embed multiedit.md
+var multieditDescription []byte
+
+func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		MultiEditToolName,
+		string(multieditDescription),
+		func(ctx context.Context, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.FilePath == "" {
+				return fantasy.NewTextErrorResponse("file_path is required"), nil
+			}
+
+			if len(params.Edits) == 0 {
+				return fantasy.NewTextErrorResponse("at least one edit operation is required"), nil
+			}
+
+			params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
+
+			// Validate all edits before applying any
+			if err := validateEdits(params.Edits); err != nil {
+				return fantasy.NewTextErrorResponse(err.Error()), nil
+			}
+
+			var response fantasy.ToolResponse
+			var err error
+
+			editCtx := editContext{ctx, permissions, files, workingDir}
+			// Handle file creation case (first edit has empty old_string)
+			if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
+				response, err = processMultiEditWithCreation(editCtx, params, call)
+			} else {
+				response, err = processMultiEditExistingFile(editCtx, params, call)
+			}
+
+			if err != nil {
+				return response, err
+			}
+
+			if response.IsError {
+				return response, nil
+			}
+
+			// Notify LSP clients about the change
+			notifyLSPs(ctx, lspClients, params.FilePath)
+
+			// Wait for LSP diagnostics and add them to the response
+			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
+			text += getDiagnostics(params.FilePath, lspClients)
+			response.Content = text
+			return response, nil
+		})
+}
+
+func validateEdits(edits []MultiEditOperation) error {
+	for i, edit := range edits {
+		// Only the first edit can have empty old_string (for file creation)
+		if i > 0 && edit.OldString == "" {
+			return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
+		}
+	}
+	return nil
+}
+
+func processMultiEditWithCreation(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+	// First edit creates the file
+	firstEdit := params.Edits[0]
+	if firstEdit.OldString != "" {
+		return fantasy.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
+	}
+
+	// Check if file already exists
+	if _, err := os.Stat(params.FilePath); err == nil {
+		return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
+	} else if !os.IsNotExist(err) {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+	}
+
+	// Create parent directories
+	dir := filepath.Dir(params.FilePath)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
+	}
+
+	// Start with the content from the first edit
+	currentContent := firstEdit.NewString
+
+	// Apply remaining edits to the content, tracking failures
+	var failedEdits []FailedEdit
+	for i := 1; i < len(params.Edits); i++ {
+		edit := params.Edits[i]
+		newContent, err := applyEditToContent(currentContent, edit)
+		if err != nil {
+			failedEdits = append(failedEdits, FailedEdit{
+				Index: i + 1,
+				Error: err.Error(),
+				Edit:  edit,
+			})
+			continue
+		}
+		currentContent = newContent
+	}
+
+	// Get session and message IDs
+	sessionID := GetSessionFromContext(edit.ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
+	}
+
+	// Check permissions
+	_, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
+
+	p := edit.permissions.Request(permission.CreatePermissionRequest{
+		SessionID:   sessionID,
+		Path:        fsext.PathOrPrefix(params.FilePath, edit.workingDir),
+		ToolCallID:  call.ID,
+		ToolName:    MultiEditToolName,
+		Action:      "write",
+		Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
+		Params: MultiEditPermissionsParams{
+			FilePath:   params.FilePath,
+			OldContent: "",
+			NewContent: currentContent,
+		},
+	})
+	if !p {
+		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+	}
+
+	// Write the file
+	err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+	}
+
+	// Update file history
+	_, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+	}
+
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
+	if err != nil {
+		slog.Debug("Error creating file history version", "error", err)
+	}
+
+	recordFileWrite(params.FilePath)
+	recordFileRead(params.FilePath)
+
+	editsApplied := len(params.Edits) - len(failedEdits)
+	var message string
+	if len(failedEdits) > 0 {
+		message = fmt.Sprintf("File created with %d of %d edits: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
+	} else {
+		message = fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)
+	}
+
+	return fantasy.WithResponseMetadata(
+		fantasy.NewTextResponse(message),
+		MultiEditResponseMetadata{
+			OldContent:   "",
+			NewContent:   currentContent,
+			Additions:    additions,
+			Removals:     removals,
+			EditsApplied: editsApplied,
+			EditsFailed:  failedEdits,
+		},
+	), nil
+}
+
+func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+	// Validate file exists and is readable
+	fileInfo, err := os.Stat(params.FilePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
+		}
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+	}
+
+	if fileInfo.IsDir() {
+		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
+	}
+
+	// Check if file was read before editing
+	if getLastReadTime(params.FilePath).IsZero() {
+		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
+	}
+
+	// Check if file was modified since last read
+	modTime := fileInfo.ModTime()
+	lastRead := getLastReadTime(params.FilePath)
+	if modTime.After(lastRead) {
+		return fantasy.NewTextErrorResponse(
+			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
+				params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
+			)), nil
+	}
+
+	// Read current file content
+	content, err := os.ReadFile(params.FilePath)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
+	}
+
+	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
+	currentContent := oldContent
+
+	// Apply all edits sequentially, tracking failures
+	var failedEdits []FailedEdit
+	for i, edit := range params.Edits {
+		newContent, err := applyEditToContent(currentContent, edit)
+		if err != nil {
+			failedEdits = append(failedEdits, FailedEdit{
+				Index: i + 1,
+				Error: err.Error(),
+				Edit:  edit,
+			})
+			continue
+		}
+		currentContent = newContent
+	}
+
+	// Check if content actually changed
+	if oldContent == currentContent {
+		// If we have failed edits, report them
+		if len(failedEdits) > 0 {
+			return fantasy.WithResponseMetadata(
+				fantasy.NewTextErrorResponse(fmt.Sprintf("no changes made - all %d edit(s) failed", len(failedEdits))),
+				MultiEditResponseMetadata{
+					EditsApplied: 0,
+					EditsFailed:  failedEdits,
+				},
+			), nil
+		}
+		return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
+	}
+
+	// Get session and message IDs
+	sessionID := GetSessionFromContext(edit.ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
+	}
+
+	// Generate diff and check permissions
+	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
+	p := edit.permissions.Request(permission.CreatePermissionRequest{
+		SessionID:   sessionID,
+		Path:        fsext.PathOrPrefix(params.FilePath, edit.workingDir),
+		ToolCallID:  call.ID,
+		ToolName:    MultiEditToolName,
+		Action:      "write",
+		Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
+		Params: MultiEditPermissionsParams{
+			FilePath:   params.FilePath,
+			OldContent: oldContent,
+			NewContent: currentContent,
+		},
+	})
+	if !p {
+		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+	}
+
+	if isCrlf {
+		currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
+	}
+
+	// Write the updated content
+	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+	}
+
+	// Update file history
+	file, err := edit.files.GetByPathAndSession(edit.ctx, params.FilePath, sessionID)
+	if err != nil {
+		_, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent)
+		if err != nil {
+			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+		}
+	}
+	if file.Content != oldContent {
+		// User manually changed the content, store an intermediate version
+		_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, oldContent)
+		if err != nil {
+			slog.Debug("Error creating file history version", "error", err)
+		}
+	}
+
+	// Store the new version
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
+	if err != nil {
+		slog.Debug("Error creating file history version", "error", err)
+	}
+
+	recordFileWrite(params.FilePath)
+	recordFileRead(params.FilePath)
+
+	editsApplied := len(params.Edits) - len(failedEdits)
+	var message string
+	if len(failedEdits) > 0 {
+		message = fmt.Sprintf("Applied %d of %d edits to file: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
+	} else {
+		message = fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)
+	}
+
+	return fantasy.WithResponseMetadata(
+		fantasy.NewTextResponse(message),
+		MultiEditResponseMetadata{
+			OldContent:   oldContent,
+			NewContent:   currentContent,
+			Additions:    additions,
+			Removals:     removals,
+			EditsApplied: editsApplied,
+			EditsFailed:  failedEdits,
+		},
+	), nil
+}
+
+func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
+	if edit.OldString == "" && edit.NewString == "" {
+		return content, nil
+	}
+
+	if edit.OldString == "" {
+		return "", fmt.Errorf("old_string cannot be empty for content replacement")
+	}
+
+	var newContent string
+	var replacementCount int
+
+	if edit.ReplaceAll {
+		newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
+		replacementCount = strings.Count(content, edit.OldString)
+		if replacementCount == 0 {
+			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
+		}
+	} else {
+		index := strings.Index(content, edit.OldString)
+		if index == -1 {
+			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
+		}
+
+		lastIndex := strings.LastIndex(content, edit.OldString)
+		if index != lastIndex {
+			return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
+		}
+
+		newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
+		replacementCount = 1
+	}
+
+	return newContent, nil
+}
diff --git a/internal/agent/tools/multiedit.md b/internal/agent/tools/multiedit.md
new file mode 100644
index 00000000..1f281ed9
--- /dev/null
+++ b/internal/agent/tools/multiedit.md
@@ -0,0 +1,125 @@
+Makes multiple edits to a single file in one operation. Built on Edit tool for efficient multiple find-and-replace operations. Prefer over Edit tool for multiple edits to same file.
+
+<prerequisites>
+1. Use View tool to understand file contents and context
+2. Verify directory path is correct
+3. CRITICAL: Note exact whitespace, indentation, and formatting from View output
+</prerequisites>
+
+<parameters>
+1. file_path: Absolute path to file (required)
+2. edits: Array of edit operations, each containing:
+   - old_string: Text to replace (must match exactly including whitespace/indentation)
+   - new_string: Replacement text
+   - replace_all: Replace all occurrences (optional, defaults to false)
+</parameters>
+
+<operation>
+- Edits applied sequentially in provided order.
+- Each edit operates on result of previous edit.
+- PARTIAL SUCCESS: If some edits fail, successful edits are still applied. Failed edits are returned in the response.
+- File is modified if at least one edit succeeds.
+- Ideal for several changes to different parts of same file.
+</operation>
+
+<inherited_rules>
+All instructions from the Edit tool documentation apply verbatim to every edit item:
+- Critical requirements for exact matching and uniqueness
+- Warnings and common failures (tabs vs spaces, blank lines, brace placement, etc.)
+- Verification steps before using, recovery steps, best practices, and whitespace checklist
+Use the same level of precision as Edit. Multiedit often fails due to formatting mismatches—double-check whitespace for every edit.
+</inherited_rules>
+
+<critical_requirements>
+1. Apply Edit tool rules to EACH edit (see edit.md).
+2. Edits are applied in order; successful edits are kept even if later edits fail.
+3. Plan sequence carefully: earlier edits change the file content that later edits must match.
+4. Ensure each old_string is unique at its application time (after prior edits).
+5. Check the response for failed edits and retry them if needed.
+</critical_requirements>
+
+<verification_before_using>
+1. View the file and copy exact text (including whitespace) for each target.
+2. Check how many instances each old_string has BEFORE the sequence starts.
+3. Dry-run mentally: after applying edit #N, will edit #N+1 still match? Adjust old_string/new_string accordingly.
+4. Prefer fewer, larger context blocks over many tiny fragments that are easy to misalign.
+5. If edits are independent, consider separate multiedit batches per logical region.
+</verification_before_using>
+
+<warnings>
+- Operation continues even if some edits fail; check response for failed edits.
+- Earlier edits can invalidate later matches (added/removed spaces, lines, or reordered text).
+- Mixed tabs/spaces, trailing spaces, or missing blank lines commonly cause failures.
+- replace_all may affect unintended regions—use carefully or provide more context.
+</warnings>
+
+<recovery_steps>
+If some edits fail:
+1. Check the response metadata for the list of failed edits with their error messages.
+2. View the file again to see the current state after successful edits.
+3. Adjust the failed edits based on the new file content.
+4. Retry the failed edits with corrected old_string values.
+5. Consider breaking complex batches into smaller, independent operations.
+</recovery_steps>
+
+<best_practices>
+- Ensure all edits result in correct, idiomatic code; don't leave code broken.
+- Use absolute file paths (starting with /).
+- Use replace_all only when you're certain; otherwise provide unique context.
+- Match existing style exactly (spaces, tabs, blank lines).
+- Review failed edits in the response and retry with corrections.
+</best_practices>
+
+<whitespace_checklist>
+For EACH edit, verify:
+- [ ] Viewed the file first
+- [ ] Counted indentation spaces/tabs
+- [ ] Included blank lines if present
+- [ ] Matched brace/bracket positioning
+- [ ] Included 3–5 lines of surrounding context
+- [ ] Verified text appears exactly once (or using replace_all deliberately)
+- [ ] Copied text character-for-character, not approximated
+</whitespace_checklist>
+
+<examples>
+✅ Correct: Sequential edits where the second match accounts for the first change
+
+```
+edits: [
+  {
+    old_string: "func A() {\n    doOld()\n}",
+    new_string: "func A() {\n    doNew()\n}",
+  },
+  {
+    // Uses context that still exists AFTER the first replacement
+    old_string: "func B() {\n    callA()\n}",
+    new_string: "func B() {\n    callA()\n    logChange()\n}",
+  },
+]
+```
+
+❌ Incorrect: Second old_string no longer matches due to whitespace change introduced by the first edit
+
+```
+edits: [
+  {
+    old_string: "func A() {\n    doOld()\n}",
+    new_string: "func A() {\n\n    doNew()\n}", // Added extra blank line
+  },
+  {
+    old_string: "func A() {\n    doNew()\n}", // Missing the new blank line, will FAIL
+    new_string: "func A() {\n    doNew()\n    logChange()\n}",
+  },
+]
+```
+
+✅ Correct: Handling partial success
+
+```
+// If edit 2 fails, edit 1 is still applied
+// Response will indicate:
+// - edits_applied: 1
+// - edits_failed: [{index: 2, error: "...", edit: {...}}]
+// You can then retry edit 2 with corrected context
+```
+</examples>
diff --git a/internal/llm/tools/references.go b/internal/agent/tools/references.go
similarity index 56%
rename from internal/llm/tools/references.go
rename to internal/agent/tools/references.go
index a1bc393c..7f2a0d8c 100644
--- a/internal/llm/tools/references.go
+++ b/internal/agent/tools/references.go
@@ -4,7 +4,6 @@ import (
 	"cmp"
 	"context"
 	_ "embed"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"log/slog"
@@ -15,14 +14,15 @@ import (
 	"sort"
 	"strings"
 
+	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
 
 type ReferencesParams struct {
-	Symbol string `json:"symbol"`
-	Path   string `json:"path"`
+	Symbol string `json:"symbol" description:"The symbol name to search for (e.g., function name, variable name, type name)"`
+	Path   string `json:"path,omitempty" description:"The directory to search in. Use a directory/file to narrow down the symbol search. Defaults to the current working directory."`
 }
 
 type referencesTool struct {
@@ -34,95 +34,71 @@ const ReferencesToolName = "lsp_references"
 //go:embed references.md
 var referencesDescription []byte
 
-func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) BaseTool {
-	return &referencesTool{
-		lspClients,
-	}
-}
-
-func (r *referencesTool) Name() string {
-	return ReferencesToolName
-}
-
-func (r *referencesTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        ReferencesToolName,
-		Description: string(referencesDescription),
-		Parameters: map[string]any{
-			"symbol": map[string]any{
-				"type":        "string",
-				"description": "The symbol name to search for (e.g., function name, variable name, type name).",
-			},
-			"path": map[string]any{
-				"type":        "string",
-				"description": "The directory to search in. Should be the entire project most of the time. Defaults to the current working directory.",
-			},
-		},
-		Required: []string{"symbol"},
-	}
-}
-
-func (r *referencesTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params ReferencesParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
+func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		ReferencesToolName,
+		string(referencesDescription),
+		func(ctx context.Context, params ReferencesParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.Symbol == "" {
+				return fantasy.NewTextErrorResponse("symbol is required"), nil
+			}
 
-	if params.Symbol == "" {
-		return NewTextErrorResponse("symbol is required"), nil
-	}
+			if lspClients.Len() == 0 {
+				return fantasy.NewTextErrorResponse("no LSP clients available"), nil
+			}
 
-	if r.lspClients.Len() == 0 {
-		return NewTextErrorResponse("no LSP clients available"), nil
-	}
+			workingDir := cmp.Or(params.Path, ".")
 
-	workingDir := cmp.Or(params.Path, ".")
+			matches, _, err := searchFiles(ctx, regexp.QuoteMeta(params.Symbol), workingDir, "", 100)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to search for symbol: %s", err)), nil
+			}
 
-	matches, _, err := searchFiles(ctx, regexp.QuoteMeta(params.Symbol), workingDir, "", 100)
-	if err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("failed to search for symbol: %s", err)), nil
-	}
+			if len(matches) == 0 {
+				return fantasy.NewTextResponse(fmt.Sprintf("Symbol '%s' not found", params.Symbol)), nil
+			}
 
-	if len(matches) == 0 {
-		return NewTextResponse(fmt.Sprintf("Symbol '%s' not found", params.Symbol)), nil
-	}
+			var allLocations []protocol.Location
+			var allErrs error
+			for _, match := range matches {
+				locations, err := find(ctx, lspClients, params.Symbol, match)
+				if err != nil {
+					if strings.Contains(err.Error(), "no identifier found") {
+						// grep probably matched a comment, string value, or something else that's irrelevant
+						continue
+					}
+					slog.Error("Failed to find references", "error", err, "symbol", params.Symbol, "path", match.path, "line", match.lineNum, "char", match.charNum)
+					allErrs = errors.Join(allErrs, err)
+					continue
+				}
+				allLocations = append(allLocations, locations...)
+				// XXX: should we break here or look for all results?
+			}
 
-	var allLocations []protocol.Location
-	var allErrs error
-	for _, match := range matches {
-		locations, err := r.find(ctx, params.Symbol, match)
-		if err != nil {
-			if strings.Contains(err.Error(), "no identifier found") {
-				// grep probably matched a comment, string value, or something else that's irrelevant
-				continue
+			if len(allLocations) > 0 {
+				output := formatReferences(cleanupLocations(allLocations))
+				return fantasy.NewTextResponse(output), nil
 			}
-			slog.Error("Failed to find references", "error", err, "symbol", params.Symbol, "path", match.path, "line", match.lineNum, "char", match.charNum)
-			allErrs = errors.Join(allErrs, err)
-			continue
-		}
-		allLocations = append(allLocations, locations...)
-		// XXX: should we break here or look for all results?
-	}
 
-	if len(allLocations) > 0 {
-		output := formatReferences(cleanupLocations(allLocations))
-		return NewTextResponse(output), nil
-	}
+			if allErrs != nil {
+				return fantasy.NewTextErrorResponse(allErrs.Error()), nil
+			}
+			return fantasy.NewTextResponse(fmt.Sprintf("No references found for symbol '%s'", params.Symbol)), nil
+		})
+}
 
-	if allErrs != nil {
-		return NewTextErrorResponse(allErrs.Error()), nil
-	}
-	return NewTextResponse(fmt.Sprintf("No references found for symbol '%s'", params.Symbol)), nil
+func (r *referencesTool) Name() string {
+	return ReferencesToolName
 }
 
-func (r *referencesTool) find(ctx context.Context, symbol string, match grepMatch) ([]protocol.Location, error) {
+func find(ctx context.Context, lspClients *csync.Map[string, *lsp.Client], symbol string, match grepMatch) ([]protocol.Location, error) {
 	absPath, err := filepath.Abs(match.path)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get absolute path: %s", err)
 	}
 
 	var client *lsp.Client
-	for c := range r.lspClients.Seq() {
+	for c := range lspClients.Seq() {
 		if c.HandlesFile(absPath) {
 			client = c
 			break
diff --git a/internal/agent/tools/references.md b/internal/agent/tools/references.md
new file mode 100644
index 00000000..aff814b8
--- /dev/null
+++ b/internal/agent/tools/references.md
@@ -0,0 +1,26 @@
+Find all references to/usage of a symbol by name using the Language Server Protocol (LSP).
+
+<usage>
+- Provide symbol name (e.g., "MyFunction", "myVariable", "MyType").
+- Optional path to narrow search to a directory or file (defaults to current directory).
+- Tool automatically locates the symbol and returns all references.
+</usage>
+
+<features>
+- Semantic-aware reference search (more accurate than grep/glob).
+- Returns references grouped by file with line and column numbers.
+- Supports multiple programming languages via LSP.
+- Finds only real references (not comments or unrelated strings).
+</features>
+
+<limitations>
+- May not find references in files not opened or indexed by the LSP server.
+- Results depend on the capabilities of the active LSP providers.
+</limitations>
+
+<tips>
+- Use this first when searching for where a symbol is used.
+- Do not use grep/glob for symbol searches.
+- Narrow scope with the path parameter for faster, more relevant results.
+- Use qualified names (e.g., pkg.Func, Class.method) for higher precision.
+</tips>
diff --git a/internal/llm/tools/rg.go b/internal/agent/tools/rg.go
similarity index 100%
rename from internal/llm/tools/rg.go
rename to internal/agent/tools/rg.go
diff --git a/internal/llm/tools/safe.go b/internal/agent/tools/safe.go
similarity index 100%
rename from internal/llm/tools/safe.go
rename to internal/agent/tools/safe.go
diff --git a/internal/agent/tools/sourcegraph.go b/internal/agent/tools/sourcegraph.go
new file mode 100644
index 00000000..5216405e
--- /dev/null
+++ b/internal/agent/tools/sourcegraph.go
@@ -0,0 +1,267 @@
+package tools
+
+import (
+	"bytes"
+	"context"
+	_ "embed"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+)
+
+type SourcegraphParams struct {
+	Query         string `json:"query" description:"The Sourcegraph search query"`
+	Count         int    `json:"count,omitempty" description:"Optional number of results to return (default: 10, max: 20)"`
+	ContextWindow int    `json:"context_window,omitempty" description:"The context around the match to return (default: 10 lines)"`
+	Timeout       int    `json:"timeout,omitempty" description:"Optional timeout in seconds (max 120)"`
+}
+
+type SourcegraphResponseMetadata struct {
+	NumberOfMatches int  `json:"number_of_matches"`
+	Truncated       bool `json:"truncated"`
+}
+
+const SourcegraphToolName = "sourcegraph"
+
+//go:embed sourcegraph.md
+var sourcegraphDescription []byte
+
+func NewSourcegraphTool(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(
+		SourcegraphToolName,
+		string(sourcegraphDescription),
+		func(ctx context.Context, params SourcegraphParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.Query == "" {
+				return fantasy.NewTextErrorResponse("Query parameter is required"), nil
+			}
+
+			if params.Count <= 0 {
+				params.Count = 10
+			} else if params.Count > 20 {
+				params.Count = 20 // Limit to 20 results
+			}
+
+			if params.ContextWindow <= 0 {
+				params.ContextWindow = 10 // Default context window
+			}
+
+			// Handle timeout with context
+			requestCtx := ctx
+			if params.Timeout > 0 {
+				maxTimeout := 120 // 2 minutes
+				if params.Timeout > maxTimeout {
+					params.Timeout = maxTimeout
+				}
+				var cancel context.CancelFunc
+				requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
+				defer cancel()
+			}
+
+			type graphqlRequest struct {
+				Query     string `json:"query"`
+				Variables struct {
+					Query string `json:"query"`
+				} `json:"variables"`
+			}
+
+			request := graphqlRequest{
+				Query: "query Search($query: String!) { search(query: $query, version: V2, patternType: keyword ) { results { matchCount, limitHit, resultCount, approximateResultCount, missing { name }, timedout { name }, indexUnavailable, results { __typename, ... on FileMatch { repository { name }, file { path, url, content }, lineMatches { preview, lineNumber, offsetAndLengths } } } } } }",
+			}
+			request.Variables.Query = params.Query
+
+			graphqlQueryBytes, err := json.Marshal(request)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err)
+			}
+			graphqlQuery := string(graphqlQueryBytes)
+
+			req, err := http.NewRequestWithContext(
+				requestCtx,
+				"POST",
+				"https://sourcegraph.com/.api/graphql",
+				bytes.NewBuffer([]byte(graphqlQuery)),
+			)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
+			}
+
+			req.Header.Set("Content-Type", "application/json")
+			req.Header.Set("User-Agent", "crush/1.0")
+
+			resp, err := client.Do(req)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
+			}
+			defer resp.Body.Close()
+
+			if resp.StatusCode != http.StatusOK {
+				body, _ := io.ReadAll(resp.Body)
+				if len(body) > 0 {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil
+				}
+
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
+			}
+			body, err := io.ReadAll(resp.Body)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to read response body: %w", err)
+			}
+
+			var result map[string]any
+			if err = json.Unmarshal(body, &result); err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err)
+			}
+
+			formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
+			if err != nil {
+				return fantasy.NewTextErrorResponse("Failed to format results: " + err.Error()), nil
+			}
+
+			return fantasy.NewTextResponse(formattedResults), nil
+		})
+}
+
+func formatSourcegraphResults(result map[string]any, contextWindow int) (string, error) {
+	var buffer strings.Builder
+
+	if errors, ok := result["errors"].([]any); ok && len(errors) > 0 {
+		buffer.WriteString("## Sourcegraph API Error\n\n")
+		for _, err := range errors {
+			if errMap, ok := err.(map[string]any); ok {
+				if message, ok := errMap["message"].(string); ok {
+					buffer.WriteString(fmt.Sprintf("- %s\n", message))
+				}
+			}
+		}
+		return buffer.String(), nil
+	}
+
+	data, ok := result["data"].(map[string]any)
+	if !ok {
+		return "", fmt.Errorf("invalid response format: missing data field")
+	}
+
+	search, ok := data["search"].(map[string]any)
+	if !ok {
+		return "", fmt.Errorf("invalid response format: missing search field")
+	}
+
+	searchResults, ok := search["results"].(map[string]any)
+	if !ok {
+		return "", fmt.Errorf("invalid response format: missing results field")
+	}
+
+	matchCount, _ := searchResults["matchCount"].(float64)
+	resultCount, _ := searchResults["resultCount"].(float64)
+	limitHit, _ := searchResults["limitHit"].(bool)
+
+	buffer.WriteString("# Sourcegraph Search Results\n\n")
+	buffer.WriteString(fmt.Sprintf("Found %d matches across %d results\n", int(matchCount), int(resultCount)))
+
+	if limitHit {
+		buffer.WriteString("(Result limit reached, try a more specific query)\n")
+	}
+
+	buffer.WriteString("\n")
+
+	results, ok := searchResults["results"].([]any)
+	if !ok || len(results) == 0 {
+		buffer.WriteString("No results found. Try a different query.\n")
+		return buffer.String(), nil
+	}
+
+	maxResults := 10
+	if len(results) > maxResults {
+		results = results[:maxResults]
+	}
+
+	for i, res := range results {
+		fileMatch, ok := res.(map[string]any)
+		if !ok {
+			continue
+		}
+
+		typeName, _ := fileMatch["__typename"].(string)
+		if typeName != "FileMatch" {
+			continue
+		}
+
+		repo, _ := fileMatch["repository"].(map[string]any)
+		file, _ := fileMatch["file"].(map[string]any)
+		lineMatches, _ := fileMatch["lineMatches"].([]any)
+
+		if repo == nil || file == nil {
+			continue
+		}
+
+		repoName, _ := repo["name"].(string)
+		filePath, _ := file["path"].(string)
+		fileURL, _ := file["url"].(string)
+		fileContent, _ := file["content"].(string)
+
+		buffer.WriteString(fmt.Sprintf("## Result %d: %s/%s\n\n", i+1, repoName, filePath))
+
+		if fileURL != "" {
+			buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL))
+		}
+
+		if len(lineMatches) > 0 {
+			for _, lm := range lineMatches {
+				lineMatch, ok := lm.(map[string]any)
+				if !ok {
+					continue
+				}
+
+				lineNumber, _ := lineMatch["lineNumber"].(float64)
+				preview, _ := lineMatch["preview"].(string)
+
+				if fileContent != "" {
+					lines := strings.Split(fileContent, "\n")
+
+					buffer.WriteString("```\n")
+
+					startLine := max(1, int(lineNumber)-contextWindow)
+
+					for j := startLine - 1; j < int(lineNumber)-1 && j < len(lines); j++ {
+						if j >= 0 {
+							buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
+						}
+					}
+
+					buffer.WriteString(fmt.Sprintf("%d|  %s\n", int(lineNumber), preview))
+
+					endLine := int(lineNumber) + contextWindow
+
+					for j := int(lineNumber); j < endLine && j < len(lines); j++ {
+						if j < len(lines) {
+							buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
+						}
+					}
+
+					buffer.WriteString("```\n\n")
+				} else {
+					buffer.WriteString("```\n")
+					buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
+					buffer.WriteString("```\n\n")
+				}
+			}
+		}
+	}
+
+	return buffer.String(), nil
+}
diff --git a/internal/agent/tools/sourcegraph.md b/internal/agent/tools/sourcegraph.md
new file mode 100644
index 00000000..6e0e3fb7
--- /dev/null
+++ b/internal/agent/tools/sourcegraph.md
@@ -0,0 +1,55 @@
+Search code across public repositories using Sourcegraph's GraphQL API.
+
+<usage>
+- Provide search query using Sourcegraph syntax
+- Optional result count (default: 10, max: 20)
+- Optional timeout for request
+</usage>
+
+<basic_syntax>
+- "fmt.Println" - exact matches
+- "file:.go fmt.Println" - limit to Go files
+- "repo:^github\.com/golang/go$ fmt.Println" - specific repos
+- "lang:go fmt.Println" - limit to Go code
+- "fmt.Println AND log.Fatal" - combined terms
+- "fmt\.(Print|Printf|Println)" - regex patterns
+- "\"exact phrase\"" - exact phrase matching
+- "-file:test" or "-repo:forks" - exclude matches
+</basic_syntax>
+
+<key_filters>
+Repository: repo:name, repo:^exact$, repo:org/repo@branch, -repo:exclude, fork:yes, archived:yes, visibility:public
+File: file:\.js$, file:internal/, -file:test, file:has.content(text)
+Content: content:"exact", -content:"unwanted", case:yes
+Type: type:symbol, type:file, type:path, type:diff, type:commit
+Time: after:"1 month ago", before:"2023-01-01", author:name, message:"fix"
+Result: select:repo, select:file, select:content, count:100, timeout:30s
+</key_filters>
+
+<examples>
+- "file:.go context.WithTimeout" - Go code using context.WithTimeout
+- "lang:typescript useState type:symbol" - TypeScript React useState hooks
+- "repo:^github\.com/kubernetes/kubernetes$ pod list type:file" - Kubernetes pod files
+- "file:Dockerfile (alpine OR ubuntu) -content:alpine:latest" - Dockerfiles with base images
+</examples>
+
+<boolean_operators>
+- "term1 AND term2" - both terms
+- "term1 OR term2" - either term
+- "term1 NOT term2" - term1 but not term2
+- "term1 and (term2 or term3)" - grouping with parentheses
+</boolean_operators>
+
+<limitations>
+- Only searches public repositories
+- Rate limits may apply
+- Complex queries take longer
+- Max 20 results per query
+</limitations>
+
+<tips>
+- Use specific file extensions to narrow results
+- Add repo: filters for targeted searches
+- Use type:symbol for function/method definitions
+- Use type:file to find relevant files
+</tips>
diff --git a/internal/agent/tools/tools.go b/internal/agent/tools/tools.go
new file mode 100644
index 00000000..365d7a33
--- /dev/null
+++ b/internal/agent/tools/tools.go
@@ -0,0 +1,39 @@
+package tools
+
+import (
+	"context"
+)
+
+type (
+	sessionIDContextKey string
+	messageIDContextKey string
+)
+
+const (
+	SessionIDContextKey sessionIDContextKey = "session_id"
+	MessageIDContextKey messageIDContextKey = "message_id"
+)
+
+func GetSessionFromContext(ctx context.Context) string {
+	sessionID := ctx.Value(SessionIDContextKey)
+	if sessionID == nil {
+		return ""
+	}
+	s, ok := sessionID.(string)
+	if !ok {
+		return ""
+	}
+	return s
+}
+
+func GetMessageFromContext(ctx context.Context) string {
+	messageID := ctx.Value(MessageIDContextKey)
+	if messageID == nil {
+		return ""
+	}
+	s, ok := messageID.(string)
+	if !ok {
+		return ""
+	}
+	return s
+}
diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go
new file mode 100644
index 00000000..39a2e9d1
--- /dev/null
+++ b/internal/agent/tools/view.go
@@ -0,0 +1,311 @@
+package tools
+
+import (
+	"bufio"
+	"context"
+	_ "embed"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"unicode/utf8"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+//go:embed view.md
+var viewDescription []byte
+
+type ViewParams struct {
+	FilePath string `json:"file_path" description:"The path to the file to read"`
+	Offset   int    `json:"offset,omitempty" description:"The line number to start reading from (0-based)"`
+	Limit    int    `json:"limit,omitempty" description:"The number of lines to read (defaults to 2000)"`
+}
+
+type ViewPermissionsParams struct {
+	FilePath string `json:"file_path"`
+	Offset   int    `json:"offset"`
+	Limit    int    `json:"limit"`
+}
+
+type viewTool struct {
+	lspClients  *csync.Map[string, *lsp.Client]
+	workingDir  string
+	permissions permission.Service
+}
+
+type ViewResponseMetadata struct {
+	FilePath string `json:"file_path"`
+	Content  string `json:"content"`
+}
+
+const (
+	ViewToolName     = "view"
+	MaxReadSize      = 250 * 1024
+	DefaultReadLimit = 2000
+	MaxLineLength    = 2000
+)
+
+func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		ViewToolName,
+		string(viewDescription),
+		func(ctx context.Context, params ViewParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.FilePath == "" {
+				return fantasy.NewTextErrorResponse("file_path is required"), nil
+			}
+
+			// Handle relative paths
+			filePath := filepathext.SmartJoin(workingDir, params.FilePath)
+
+			// Check if file is outside working directory and request permission if needed
+			absWorkingDir, err := filepath.Abs(workingDir)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
+			}
+
+			absFilePath, err := filepath.Abs(filePath)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
+			}
+
+			relPath, err := filepath.Rel(absWorkingDir, absFilePath)
+			if err != nil || strings.HasPrefix(relPath, "..") {
+				// File is outside working directory, request permission
+				sessionID := GetSessionFromContext(ctx)
+				if sessionID == "" {
+					return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
+				}
+
+				granted := permissions.Request(
+					permission.CreatePermissionRequest{
+						SessionID:   sessionID,
+						Path:        absFilePath,
+						ToolCallID:  call.ID,
+						ToolName:    ViewToolName,
+						Action:      "read",
+						Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
+						Params:      ViewPermissionsParams(params),
+					},
+				)
+
+				if !granted {
+					return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+				}
+			}
+
+			// Check if file exists
+			fileInfo, err := os.Stat(filePath)
+			if err != nil {
+				if os.IsNotExist(err) {
+					// Try to offer suggestions for similarly named files
+					dir := filepath.Dir(filePath)
+					base := filepath.Base(filePath)
+
+					dirEntries, dirErr := os.ReadDir(dir)
+					if dirErr == nil {
+						var suggestions []string
+						for _, entry := range dirEntries {
+							if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
+								strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
+								suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
+								if len(suggestions) >= 3 {
+									break
+								}
+							}
+						}
+
+						if len(suggestions) > 0 {
+							return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
+								filePath, strings.Join(suggestions, "\n"))), nil
+						}
+					}
+
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
+				}
+				return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
+			}
+
+			// Check if it's a directory
+			if fileInfo.IsDir() {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
+			}
+
+			// Check file size
+			if fileInfo.Size() > MaxReadSize {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
+					fileInfo.Size(), MaxReadSize)), nil
+			}
+
+			// Set default limit if not provided
+			if params.Limit <= 0 {
+				params.Limit = DefaultReadLimit
+			}
+
+			// Check if it's an image file
+			isImage, imageType := isImageFile(filePath)
+			// TODO: handle images
+			if isImage {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
+			}
+
+			// Read the file content
+			content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
+			isValidUt8 := utf8.ValidString(content)
+			if !isValidUt8 {
+				return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
+			}
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
+			}
+
+			notifyLSPs(ctx, lspClients, filePath)
+			output := "<file>\n"
+			// Format the output with line numbers
+			output += addLineNumbers(content, params.Offset+1)
+
+			// Add a note if the content was truncated
+			if lineCount > params.Offset+len(strings.Split(content, "\n")) {
+				output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
+					params.Offset+len(strings.Split(content, "\n")))
+			}
+			output += "\n</file>\n"
+			output += getDiagnostics(filePath, lspClients)
+			recordFileRead(filePath)
+			return fantasy.WithResponseMetadata(
+				fantasy.NewTextResponse(output),
+				ViewResponseMetadata{
+					FilePath: filePath,
+					Content:  content,
+				},
+			), nil
+		})
+}
+
+func addLineNumbers(content string, startLine int) string {
+	if content == "" {
+		return ""
+	}
+
+	lines := strings.Split(content, "\n")
+
+	var result []string
+	for i, line := range lines {
+		line = strings.TrimSuffix(line, "\r")
+
+		lineNum := i + startLine
+		numStr := fmt.Sprintf("%d", lineNum)
+
+		if len(numStr) >= 6 {
+			result = append(result, fmt.Sprintf("%s|%s", numStr, line))
+		} else {
+			paddedNum := fmt.Sprintf("%6s", numStr)
+			result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
+		}
+	}
+
+	return strings.Join(result, "\n")
+}
+
+func readTextFile(filePath string, offset, limit int) (string, int, error) {
+	file, err := os.Open(filePath)
+	if err != nil {
+		return "", 0, err
+	}
+	defer file.Close()
+
+	lineCount := 0
+
+	scanner := NewLineScanner(file)
+	if offset > 0 {
+		for lineCount < offset && scanner.Scan() {
+			lineCount++
+		}
+		if err = scanner.Err(); err != nil {
+			return "", 0, err
+		}
+	}
+
+	if offset == 0 {
+		_, err = file.Seek(0, io.SeekStart)
+		if err != nil {
+			return "", 0, err
+		}
+	}
+
+	// Pre-allocate slice with expected capacity
+	lines := make([]string, 0, limit)
+	lineCount = offset
+
+	for scanner.Scan() && len(lines) < limit {
+		lineCount++
+		lineText := scanner.Text()
+		if len(lineText) > MaxLineLength {
+			lineText = lineText[:MaxLineLength] + "..."
+		}
+		lines = append(lines, lineText)
+	}
+
+	// Continue scanning to get total line count
+	for scanner.Scan() {
+		lineCount++
+	}
+
+	if err := scanner.Err(); err != nil {
+		return "", 0, err
+	}
+
+	return strings.Join(lines, "\n"), lineCount, nil
+}
+
+func isImageFile(filePath string) (bool, string) {
+	ext := strings.ToLower(filepath.Ext(filePath))
+	switch ext {
+	case ".jpg", ".jpeg":
+		return true, "JPEG"
+	case ".png":
+		return true, "PNG"
+	case ".gif":
+		return true, "GIF"
+	case ".bmp":
+		return true, "BMP"
+	case ".svg":
+		return true, "SVG"
+	case ".webp":
+		return true, "WebP"
+	default:
+		return false, ""
+	}
+}
+
+type LineScanner struct {
+	scanner *bufio.Scanner
+}
+
+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: scanner,
+	}
+}
+
+func (s *LineScanner) Scan() bool {
+	return s.scanner.Scan()
+}
+
+func (s *LineScanner) Text() string {
+	return s.scanner.Text()
+}
+
+func (s *LineScanner) Err() error {
+	return s.scanner.Err()
+}
diff --git a/internal/agent/tools/view.md b/internal/agent/tools/view.md
new file mode 100644
index 00000000..8a71559c
--- /dev/null
+++ b/internal/agent/tools/view.md
@@ -0,0 +1,35 @@
+Reads and displays file contents with line numbers for examining code, logs, or text data.
+
+<usage>
+- Provide file path to read
+- Optional offset: start reading from specific line (0-based)
+- Optional limit: control lines read (default 2000)
+- Don't use for directories (use LS tool instead)
+</usage>
+
+<features>
+- Displays contents with line numbers
+- Can read from any file position using offset
+- Handles large files by limiting lines read
+- Auto-truncates very long lines for display
+- Suggests similar filenames when file not found
+</features>
+
+<limitations>
+- Max file size: 250KB
+- Default limit: 2000 lines
+- Lines >2000 chars truncated
+- Cannot display binary files/images (identifies them)
+</limitations>
+
+<cross_platform>
+- Handles Windows (CRLF) and Unix (LF) line endings
+- Works with forward slashes (/) and backslashes (\)
+- Auto-detects text encoding for common formats
+</cross_platform>
+
+<tips>
+- Use with Glob to find files first
+- For code exploration: Grep to find relevant files, then View to examine
+- For large files: use offset parameter for specific sections
+</tips>
diff --git a/internal/agent/tools/web_fetch.go b/internal/agent/tools/web_fetch.go
new file mode 100644
index 00000000..42a5f928
--- /dev/null
+++ b/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
+		})
+}
diff --git a/internal/agent/tools/web_fetch.md b/internal/agent/tools/web_fetch.md
new file mode 100644
index 00000000..d39d0157
--- /dev/null
+++ b/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>
diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go
new file mode 100644
index 00000000..0868b9f6
--- /dev/null
+++ b/internal/agent/tools/write.go
@@ -0,0 +1,175 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/history"
+
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+//go:embed write.md
+var writeDescription []byte
+
+type WriteParams struct {
+	FilePath string `json:"file_path" description:"The path to the file to write"`
+	Content  string `json:"content" description:"The content to write to the file"`
+}
+
+type WritePermissionsParams struct {
+	FilePath   string `json:"file_path"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+type writeTool struct {
+	lspClients  *csync.Map[string, *lsp.Client]
+	permissions permission.Service
+	files       history.Service
+	workingDir  string
+}
+
+type WriteResponseMetadata struct {
+	Diff      string `json:"diff"`
+	Additions int    `json:"additions"`
+	Removals  int    `json:"removals"`
+}
+
+const WriteToolName = "write"
+
+func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		WriteToolName,
+		string(writeDescription),
+		func(ctx context.Context, params WriteParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.FilePath == "" {
+				return fantasy.NewTextErrorResponse("file_path is required"), nil
+			}
+
+			if params.Content == "" {
+				return fantasy.NewTextErrorResponse("content is required"), nil
+			}
+
+			filePath := filepathext.SmartJoin(workingDir, params.FilePath)
+
+			fileInfo, err := os.Stat(filePath)
+			if err == nil {
+				if fileInfo.IsDir() {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
+				}
+
+				modTime := fileInfo.ModTime()
+				lastRead := getLastReadTime(filePath)
+				if modTime.After(lastRead) {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
+						filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
+				}
+
+				oldContent, readErr := os.ReadFile(filePath)
+				if readErr == nil && string(oldContent) == params.Content {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
+				}
+			} else if !os.IsNotExist(err) {
+				return fantasy.ToolResponse{}, fmt.Errorf("error checking file: %w", err)
+			}
+
+			dir := filepath.Dir(filePath)
+			if err = os.MkdirAll(dir, 0o755); err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
+			}
+
+			oldContent := ""
+			if fileInfo != nil && !fileInfo.IsDir() {
+				oldBytes, readErr := os.ReadFile(filePath)
+				if readErr == nil {
+					oldContent = string(oldBytes)
+				}
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
+			}
+
+			diff, additions, removals := diff.GenerateDiff(
+				oldContent,
+				params.Content,
+				strings.TrimPrefix(filePath, workingDir),
+			)
+
+			p := permissions.Request(
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        fsext.PathOrPrefix(filePath, workingDir),
+					ToolCallID:  call.ID,
+					ToolName:    WriteToolName,
+					Action:      "write",
+					Description: fmt.Sprintf("Create file %s", filePath),
+					Params: WritePermissionsParams{
+						FilePath:   filePath,
+						OldContent: oldContent,
+						NewContent: params.Content,
+					},
+				},
+			)
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			err = os.WriteFile(filePath, []byte(params.Content), 0o644)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error writing file: %w", err)
+			}
+
+			// Check if file exists in history
+			file, err := files.GetByPathAndSession(ctx, filePath, sessionID)
+			if err != nil {
+				_, err = files.Create(ctx, sessionID, filePath, oldContent)
+				if err != nil {
+					// Log error but don't fail the operation
+					return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+				}
+			}
+			if file.Content != oldContent {
+				// User Manually changed the content store an intermediate version
+				_, err = files.CreateVersion(ctx, sessionID, filePath, oldContent)
+				if err != nil {
+					slog.Debug("Error creating file history version", "error", err)
+				}
+			}
+			// Store the new version
+			_, err = files.CreateVersion(ctx, sessionID, filePath, params.Content)
+			if err != nil {
+				slog.Debug("Error creating file history version", "error", err)
+			}
+
+			recordFileWrite(filePath)
+			recordFileRead(filePath)
+
+			notifyLSPs(ctx, lspClients, params.FilePath)
+
+			result := fmt.Sprintf("File successfully written: %s", filePath)
+			result = fmt.Sprintf("<result>\n%s\n</result>", result)
+			result += getDiagnostics(filePath, lspClients)
+			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
+				WriteResponseMetadata{
+					Diff:      diff,
+					Additions: additions,
+					Removals:  removals,
+				},
+			), nil
+		})
+}
diff --git a/internal/agent/tools/write.md b/internal/agent/tools/write.md
new file mode 100644
index 00000000..6cdf0351
--- /dev/null
+++ b/internal/agent/tools/write.md
@@ -0,0 +1,30 @@
+Creates or updates files in filesystem for saving/modifying text content.
+
+<usage>
+- Provide file path to write
+- Include content to write to file
+- Tool creates necessary parent directories automatically
+</usage>
+
+<features>
+- Creates new files or overwrites existing ones
+- Auto-creates parent directories if missing
+- Checks if file modified since last read for safety
+- Avoids unnecessary writes when content unchanged
+</features>
+
+<limitations>
+- Read file before writing to avoid conflicts
+- Cannot append (rewrites entire file)
+</limitations>
+
+<cross_platform>
+- Use forward slashes (/) for compatibility
+</cross_platform>
+
+<tips>
+- Use View tool first to examine existing files before modifying
+- Use LS tool to verify location when creating new files
+- Combine with Glob/Grep to find and modify multiple files
+- Include descriptive comments when changing existing code
+</tips>
diff --git a/internal/app/app.go b/internal/app/app.go
index 8f305f76..1169e402 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -1,3 +1,5 @@
+// Package app wires together services, coordinates agents, and manages
+// application lifecycle.
 package app
 
 import (
@@ -5,24 +7,33 @@ import (
 	"database/sql"
 	"errors"
 	"fmt"
+	"io"
 	"log/slog"
+	"os"
 	"sync"
 	"time"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/fantasy"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent"
+	"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"
 	"github.com/charmbracelet/crush/internal/format"
 	"github.com/charmbracelet/crush/internal/history"
-	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/message"
 	"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 {
@@ -31,7 +42,7 @@ type App struct {
 	History     history.Service
 	Permissions permission.Service
 
-	CoderAgent agent.Service
+	AgentCoordinator agent.Coordinator
 
 	LSPClients *csync.Map[string, *lsp.Client]
 
@@ -80,16 +91,21 @@ 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() {
-		if err := app.InitCoderAgent(); err != nil {
-			return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
-		}
-	} else {
+	if !cfg.IsConfigured() {
 		slog.Warn("No agent configuration found")
+		return app, nil
+	}
+	if err := app.InitCoderAgent(ctx); err != nil {
+		return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
 	}
 	return app, nil
 }
@@ -99,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)
@@ -109,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()
 	}
 
@@ -123,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 {
@@ -139,45 +173,59 @@ 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)
 
-	done, err := app.CoderAgent.Run(ctx, sess.ID, prompt)
-	if err != nil {
-		return fmt.Errorf("failed to start agent processing stream: %w", err)
+	type response struct {
+		result *fantasy.AgentResult
+		err    error
 	}
+	done := make(chan response, 1)
+
+	go func(ctx context.Context, sessionID, prompt string) {
+		result, err := app.AgentCoordinator.Run(ctx, sess.ID, prompt)
+		if err != nil {
+			done <- response{
+				err: fmt.Errorf("failed to start agent processing stream: %w", err),
+			}
+		}
+		done <- response{
+			result: result,
+		}
+	}(ctx, sess.ID, prompt)
 
 	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()
-
-			if result.Error != nil {
-				if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, agent.ErrRequestCancelled) {
+			if result.err != nil {
+				if errors.Is(result.err, context.Canceled) || errors.Is(result.err, agent.ErrRequestCancelled) {
 					slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
 					return nil
 				}
-				return fmt.Errorf("agent processing failed: %w", result.Error)
-			}
-
-			msgContent := result.Message.Content().String()
-			readBts := messageReadBytes[result.Message.ID]
-
-			if len(msgContent) < readBts {
-				slog.Error("Non-interactive: message content is shorter than read bytes", "message_length", len(msgContent), "read_bytes", readBts)
-				return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(msgContent), readBts)
+				return fmt.Errorf("agent processing failed: %w", result.err)
 			}
-			fmt.Println(msgContent[readBts:])
-			messageReadBytes[result.Message.ID] = len(msgContent)
-
-			slog.Info("Non-interactive: run completed", "session_id", sess.ID)
 			return nil
 
 		case event := <-messageEvents:
@@ -194,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)
 			}
 
@@ -205,8 +253,8 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 	}
 }
 
-func (app *App) UpdateAgentModel() error {
-	return app.CoderAgent.UpdateModel()
+func (app *App) UpdateAgentModel(ctx context.Context) error {
+	return app.AgentCoordinator.UpdateModels(ctx)
 }
 
 func (app *App) setupEvents() {
@@ -217,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", agent.SubscribeMCPEvents, app.events)
+	setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
 	setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
 	cleanupFunc := func() error {
 		cancel()
@@ -260,18 +308,18 @@ func setupSubscriber[T any](
 	})
 }
 
-func (app *App) InitCoderAgent() error {
-	coderAgentCfg := app.config.Agents["coder"]
+func (app *App) InitCoderAgent(ctx context.Context) error {
+	coderAgentCfg := app.config.Agents[config.AgentCoder]
 	if coderAgentCfg.ID == "" {
 		return fmt.Errorf("coder agent configuration is missing")
 	}
 	var err error
-	app.CoderAgent, err = agent.NewAgent(
-		app.globalCtx,
-		coderAgentCfg,
-		app.Permissions,
+	app.AgentCoordinator, err = agent.NewCoordinator(
+		ctx,
+		app.config,
 		app.Sessions,
 		app.Messages,
+		app.Permissions,
 		app.History,
 		app.LSPClients,
 	)
@@ -279,11 +327,6 @@ func (app *App) InitCoderAgent() error {
 		slog.Error("Failed to create coder agent", "err", err)
 		return err
 	}
-
-	// Add MCP client cleanup to shutdown process
-	app.cleanupFuncs = append(app.cleanupFuncs, agent.CloseMCPClients)
-
-	setupSubscriber(app.eventsCtx, app.serviceEventsWG, "coderAgent", app.CoderAgent.Subscribe, app.events)
 	return nil
 }
 
@@ -321,8 +364,8 @@ func (app *App) Subscribe(program *tea.Program) {
 
 // Shutdown performs a graceful shutdown of the application.
 func (app *App) Shutdown() {
-	if app.CoderAgent != nil {
-		app.CoderAgent.CancelAll()
+	if app.AgentCoordinator != nil {
+		app.AgentCoordinator.CancelAll()
 	}
 
 	// Shutdown all LSP clients.
diff --git a/internal/cmd/dirs.go b/internal/cmd/dirs.go
index d3bc0bd4..334a7742 100644
--- a/internal/cmd/dirs.go
+++ b/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"
 )
diff --git a/internal/cmd/logs.go b/internal/cmd/logs.go
index e7160f4a..43720831 100644
--- a/internal/cmd/logs.go
+++ b/internal/cmd/logs.go
@@ -10,8 +10,10 @@ import (
 	"slices"
 	"time"
 
+	"github.com/charmbracelet/colorprofile"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/x/term"
 	"github.com/nxadm/tail"
 	"github.com/spf13/cobra"
 )
@@ -45,6 +47,9 @@ var logsCmd = &cobra.Command{
 
 		log.SetLevel(log.DebugLevel)
 		log.SetOutput(os.Stdout)
+		if !term.IsTerminal(os.Stdout.Fd()) {
+			log.SetColorProfile(colorprofile.NoTTY)
+		}
 
 		cfg, err := config.Load(cwd, dataDir, false)
 		if err != nil {
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index d6a26d81..e98fd9d3 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -10,17 +10,21 @@ import (
 	"os"
 	"path/filepath"
 	"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/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"
@@ -30,7 +34,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)")
 
@@ -72,7 +75,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
 		}
@@ -81,14 +84,15 @@ crush -y
 		event.AppInitialized()
 
 		// Set up the TUI.
+		var env uv.Environ = os.Environ()
+		ui := tui.New(app)
+		ui.QueryVersion = shouldQueryTerminalVersion(env)
+
 		program := tea.NewProgram(
-			tui.New(app),
-			tea.WithAltScreen(),
+			ui,
+			tea.WithEnvironment(env),
 			tea.WithContext(cmd.Context()),
-			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
-			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
-		)
-
+			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
 		go app.Subscribe(program)
 
 		if _, err := program.Run(); err != nil {
@@ -146,6 +150,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) {
@@ -253,3 +266,17 @@ func createDotCrushDir(dir string) error {
 
 	return nil
 }
+
+func shouldQueryTerminalVersion(env uv.Environ) bool {
+	termType := env.Getenv("TERM")
+	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
+	_, okSSHTTY := env.LookupEnv("SSH_TTY")
+	return (!okTermProg && !okSSHTTY) ||
+		(!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
+		// Terminals that do support XTVERSION.
+		strings.Contains(termType, "ghostty") ||
+		strings.Contains(termType, "wezterm") ||
+		strings.Contains(termType, "alacritty") ||
+		strings.Contains(termType, "kitty") ||
+		strings.Contains(termType, "rio")
+}
diff --git a/internal/cmd/run.go b/internal/cmd/run.go
index 21923929..d3748d0f 100644
--- a/internal/cmd/run.go
+++ b/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)
 	},
 }
 
diff --git a/internal/cmd/update_providers.go b/internal/cmd/update_providers.go
index 1e771642..4949c31e 100644
--- a/internal/cmd/update_providers.go
+++ b/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"
 )
diff --git a/internal/config/config.go b/internal/config/config.go
index ff948b87..7b1cb167 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -48,6 +48,11 @@ const (
 	SelectedModelTypeSmall SelectedModelType = "small"
 )
 
+const (
+	AgentCoder string = "coder"
+	AgentTask  string = "task"
+)
+
 type SelectedModel struct {
 	// The model id as used by the provider API.
 	// Required.
@@ -59,11 +64,19 @@ type SelectedModel struct {
 	// Only used by models that use the openai provider and need this set.
 	ReasoningEffort string `json:"reasoning_effort,omitempty" jsonschema:"description=Reasoning effort level for OpenAI models that support it,enum=low,enum=medium,enum=high"`
 
-	// Overrides the default model configuration.
-	MaxTokens int64 `json:"max_tokens,omitempty" jsonschema:"description=Maximum number of tokens for model responses,minimum=1,maximum=200000,example=4096"`
-
 	// Used by anthropic models that can reason to indicate if the model should think.
 	Think bool `json:"think,omitempty" jsonschema:"description=Enable thinking mode for Anthropic models that support reasoning"`
+
+	// Overrides the default model configuration.
+	MaxTokens        int64    `json:"max_tokens,omitempty" jsonschema:"description=Maximum number of tokens for model responses,minimum=1,maximum=200000,example=4096"`
+	Temperature      *float64 `json:"temperature,omitempty" jsonschema:"description=Sampling temperature,minimum=0,maximum=1,example=0.7"`
+	TopP             *float64 `json:"top_p,omitempty" jsonschema:"description=Top-p (nucleus) sampling parameter,minimum=0,maximum=1,example=0.9"`
+	TopK             *int64   `json:"top_k,omitempty" jsonschema:"description=Top-k sampling parameter"`
+	FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" jsonschema:"description=Frequency penalty to reduce repetition"`
+	PresencePenalty  *float64 `json:"presence_penalty,omitempty" jsonschema:"description=Presence penalty to increase topic diversity"`
+
+	// Override provider specific options.
+	ProviderOptions map[string]any `json:"provider_options,omitempty" jsonschema:"description=Additional provider-specific options for the model"`
 }
 
 type ProviderConfig struct {
@@ -86,7 +99,9 @@ type ProviderConfig struct {
 	// Extra headers to send with each request to the provider.
 	ExtraHeaders map[string]string `json:"extra_headers,omitempty" jsonschema:"description=Additional HTTP headers to send with requests"`
 	// Extra body
-	ExtraBody map[string]any `json:"extra_body,omitempty" jsonschema:"description=Additional fields to include in request bodies"`
+	ExtraBody map[string]any `json:"extra_body,omitempty" jsonschema:"description=Additional fields to include in request bodies, only works with openai-compatible providers"`
+
+	ProviderOptions map[string]any `json:"provider_options,omitempty" jsonschema:"description=Additional provider-specific options for this provider"`
 
 	// Used to pass extra parameters to the provider.
 	ExtraParams map[string]string `json:"-"`
@@ -251,10 +266,6 @@ type Agent struct {
 	//  if the string array is nil, all tools from the AllowedMCP are available
 	AllowedMCP map[string][]string `json:"allowed_mcp,omitempty"`
 
-	// The list of LSPs that this agent can use
-	//  if this is nil, all LSPs are available
-	AllowedLSP []string `json:"allowed_lsp,omitempty"`
-
 	// Overrides the context paths for this agent
 	ContextPaths []string `json:"context_paths,omitempty"`
 }
@@ -292,10 +303,10 @@ type Config struct {
 
 	Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
 
+	Agents map[string]Agent `json:"-"`
+
 	// Internal
 	workingDir string `json:"-"`
-	// TODO: most likely remove this concept when I come back to it
-	Agents map[string]Agent `json:"-"`
 	// TODO: find a better way to do this this should probably not be part of the config
 	resolver       VariableResolver
 	dataConfigDir  string             `json:"-"`
@@ -461,7 +472,10 @@ func allToolNames() []string {
 		"download",
 		"edit",
 		"multiedit",
+		"lsp_diagnostics",
+		"lsp_references",
 		"fetch",
+		"agentic_fetch",
 		"glob",
 		"grep",
 		"ls",
@@ -501,16 +515,17 @@ func (c *Config) SetupAgents() {
 	allowedTools := resolveAllowedTools(allToolNames(), c.Options.DisabledTools)
 
 	agents := map[string]Agent{
-		"coder": {
-			ID:           "coder",
+		AgentCoder: {
+			ID:           AgentCoder,
 			Name:         "Coder",
 			Description:  "An agent that helps with executing coding tasks.",
 			Model:        SelectedModelTypeLarge,
 			ContextPaths: c.Options.ContextPaths,
 			AllowedTools: allowedTools,
 		},
-		"task": {
-			ID:           "task",
+
+		AgentTask: {
+			ID:           AgentCoder,
 			Name:         "Task",
 			Description:  "An agent that helps with searching for context and finding implementation details.",
 			Model:        SelectedModelTypeLarge,
@@ -518,7 +533,6 @@ func (c *Config) SetupAgents() {
 			AllowedTools: resolveReadOnlyTools(allowedTools),
 			// NO MCPs or LSPs by default
 			AllowedMCP: map[string][]string{},
-			AllowedLSP: []string{},
 		},
 	}
 	c.Agents = agents
@@ -533,7 +547,7 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
 	headers := make(map[string]string)
 	apiKey, _ := resolver.ResolveValue(c.APIKey)
 	switch c.Type {
-	case catwalk.TypeOpenAI:
+	case catwalk.TypeOpenAI, catwalk.TypeOpenAICompat, catwalk.TypeOpenRouter:
 		baseURL, _ := resolver.ResolveValue(c.BaseURL)
 		if baseURL == "" {
 			baseURL = "https://api.openai.com/v1"
@@ -552,7 +566,7 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
 		testURL = baseURL + "/models"
 		headers["x-api-key"] = apiKey
 		headers["anthropic-version"] = "2023-06-01"
-	case catwalk.TypeGemini:
+	case catwalk.TypeGoogle:
 		baseURL, _ := resolver.ResolveValue(c.BaseURL)
 		if baseURL == "" {
 			baseURL = "https://generativelanguage.googleapis.com"
diff --git a/internal/config/init.go b/internal/config/init.go
index 6807ab25..36742ed9 100644
--- a/internal/config/init.go
+++ b/internal/config/init.go
@@ -7,6 +7,8 @@ import (
 	"slices"
 	"strings"
 	"sync/atomic"
+
+	"github.com/charmbracelet/crush/internal/fsext"
 )
 
 const (
@@ -59,6 +61,15 @@ func ProjectNeedsInitialization() (bool, error) {
 		return false, nil
 	}
 
+	// If the working directory has no non-ignored files, skip initialization step
+	empty, err := dirHasNoVisibleFiles(cfg.WorkingDir())
+	if err != nil {
+		return false, fmt.Errorf("failed to check if directory is empty: %w", err)
+	}
+	if empty {
+		return false, nil
+	}
+
 	return true, nil
 }
 
@@ -90,6 +101,15 @@ func contextPathsExist(dir string) (bool, error) {
 	return false, nil
 }
 
+// dirHasNoVisibleFiles returns true if the directory has no files/dirs after applying ignore rules
+func dirHasNoVisibleFiles(dir string) (bool, error) {
+	files, _, err := fsext.ListDirectory(dir, nil, 1, 1)
+	if err != nil {
+		return false, err
+	}
+	return len(files) == 0, nil
+}
+
 func MarkProjectInitialized() error {
 	cfg := Get()
 	if cfg == nil {
diff --git a/internal/config/load.go b/internal/config/load.go
index a219b7d1..cc7e5439 100644
--- a/internal/config/load.go
+++ b/internal/config/load.go
@@ -261,7 +261,12 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
 		}
 		// default to OpenAI if not set
 		if providerConfig.Type == "" {
-			providerConfig.Type = catwalk.TypeOpenAI
+			providerConfig.Type = catwalk.TypeOpenAICompat
+		}
+		if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) {
+			slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
+			c.Providers.Del(id)
+			continue
 		}
 
 		if providerConfig.Disable {
@@ -282,12 +287,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
 			c.Providers.Del(id)
 			continue
 		}
-		if providerConfig.Type != catwalk.TypeOpenAI && providerConfig.Type != catwalk.TypeAnthropic && providerConfig.Type != catwalk.TypeGemini {
-			slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
-			c.Providers.Del(id)
-			continue
-		}
-
 		apiKey, err := resolver.ResolveValue(providerConfig.APIKey)
 		if apiKey == "" || err != nil {
 			slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
@@ -348,6 +347,13 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 	if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
 		c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
 	}
+
+	if c.Options.Attribution == nil {
+		c.Options.Attribution = &Attribution{
+			CoAuthoredBy:  true,
+			GeneratedWith: true,
+		}
+	}
 }
 
 // applyLSPDefaults applies default values from powernap to LSP configurations
@@ -494,6 +500,21 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
 				large.ReasoningEffort = largeModelSelected.ReasoningEffort
 			}
 			large.Think = largeModelSelected.Think
+			if largeModelSelected.Temperature != nil {
+				large.Temperature = largeModelSelected.Temperature
+			}
+			if largeModelSelected.TopP != nil {
+				large.TopP = largeModelSelected.TopP
+			}
+			if largeModelSelected.TopK != nil {
+				large.TopK = largeModelSelected.TopK
+			}
+			if largeModelSelected.FrequencyPenalty != nil {
+				large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
+			}
+			if largeModelSelected.PresencePenalty != nil {
+				large.PresencePenalty = largeModelSelected.PresencePenalty
+			}
 		}
 	}
 	smallModelSelected, smallModelConfigured := c.Models[SelectedModelTypeSmall]
@@ -519,7 +540,24 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
 			} else {
 				small.MaxTokens = model.DefaultMaxTokens
 			}
-			small.ReasoningEffort = smallModelSelected.ReasoningEffort
+			if smallModelSelected.ReasoningEffort != "" {
+				small.ReasoningEffort = smallModelSelected.ReasoningEffort
+			}
+			if smallModelSelected.Temperature != nil {
+				small.Temperature = smallModelSelected.Temperature
+			}
+			if smallModelSelected.TopP != nil {
+				small.TopP = smallModelSelected.TopP
+			}
+			if smallModelSelected.TopK != nil {
+				small.TopK = smallModelSelected.TopK
+			}
+			if smallModelSelected.FrequencyPenalty != nil {
+				small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
+			}
+			if smallModelSelected.PresencePenalty != nil {
+				small.PresencePenalty = smallModelSelected.PresencePenalty
+			}
 			small.Think = smallModelSelected.Think
 		}
 	}
@@ -624,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))
 }
 
diff --git a/internal/config/provider.go b/internal/config/provider.go
index 671c348f..7a98caaa 100644
--- a/internal/config/provider.go
+++ b/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) {
-	cacheIsStale, cacheExists := isCacheStale(path)
-
 	catwalkGetAndSave := func() ([]catwalk.Provider, error) {
 		providers, err := client.GetProviders()
 		if err != nil {
@@ -142,30 +139,11 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
 		return providers, nil
 	}
 
-	backgroundCacheUpdate := func() {
-		go func() {
-			slog.Info("Updating providers cache in background", "path", path)
-
-			providers, err := client.GetProviders()
-			if err != nil {
-				slog.Error("Failed to fetch providers in background from Catwalk", "error", err)
-				return
-			}
-			if len(providers) == 0 {
-				slog.Error("Empty providers list from Catwalk")
-				return
-			}
-			if err := saveProvidersInCache(path, providers); err != nil {
-				slog.Error("Failed to update providers.json in background", "error", err)
-			}
-		}()
-	}
-
 	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)
 		}
@@ -177,35 +155,14 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
 		}
 		return providers, nil
 
-	case cacheExists && !cacheIsStale:
-		slog.Info("Recent providers cache is available.", "path", path)
-
-		providers, err := loadProvidersFromCache(path)
-		if err != nil {
-			return nil, err
-		}
-		if len(providers) == 0 {
-			return catwalkGetAndSave()
-		}
-		backgroundCacheUpdate()
-		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 {
-			catwalkUrl := fmt.Sprintf("%s/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
+			catwalkUrl := fmt.Sprintf("%s/v2/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
 			return nil, fmt.Errorf("Crush was unable to fetch an updated list of providers from %s. Consider setting CRUSH_DISABLE_PROVIDER_AUTO_UPDATE=1 to use the embedded providers bundled at the time of this Crush release. You can also update providers manually. For more info see crush update-providers --help. %w", catwalkUrl, err) //nolint:staticcheck
 		}
 		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
-}
diff --git a/internal/db/connect.go b/internal/db/connect.go
index bfe768c7..5acff6ea 100644
--- a/internal/db/connect.go
+++ b/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;",
diff --git a/internal/db/db.go b/internal/db/db.go
index 62ebe013..6f57f2c2 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -1,6 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
 // versions:
-//   sqlc v1.29.0
+//   sqlc v1.30.0
 
 package db
 
diff --git a/internal/db/files.sql.go b/internal/db/files.sql.go
index a52516d2..ec8dfefc 100644
--- a/internal/db/files.sql.go
+++ b/internal/db/files.sql.go
@@ -1,6 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
 // versions:
-//   sqlc v1.29.0
+//   sqlc v1.30.0
 // source: files.sql
 
 package db
diff --git a/internal/db/messages.sql.go b/internal/db/messages.sql.go
index 81f32292..f10b9d5e 100644
--- a/internal/db/messages.sql.go
+++ b/internal/db/messages.sql.go
@@ -1,6 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
 // versions:
-//   sqlc v1.29.0
+//   sqlc v1.30.0
 // source: messages.sql
 
 package db
@@ -18,21 +18,23 @@ INSERT INTO messages (
     parts,
     model,
     provider,
+    is_summary_message,
     created_at,
     updated_at
 ) VALUES (
-    ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
+    ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
 )
-RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at, provider
+RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
 `
 
 type CreateMessageParams struct {
-	ID        string         `json:"id"`
-	SessionID string         `json:"session_id"`
-	Role      string         `json:"role"`
-	Parts     string         `json:"parts"`
-	Model     sql.NullString `json:"model"`
-	Provider  sql.NullString `json:"provider"`
+	ID               string         `json:"id"`
+	SessionID        string         `json:"session_id"`
+	Role             string         `json:"role"`
+	Parts            string         `json:"parts"`
+	Model            sql.NullString `json:"model"`
+	Provider         sql.NullString `json:"provider"`
+	IsSummaryMessage int64          `json:"is_summary_message"`
 }
 
 func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
@@ -43,6 +45,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
 		arg.Parts,
 		arg.Model,
 		arg.Provider,
+		arg.IsSummaryMessage,
 	)
 	var i Message
 	err := row.Scan(
@@ -55,6 +58,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
 		&i.UpdatedAt,
 		&i.FinishedAt,
 		&i.Provider,
+		&i.IsSummaryMessage,
 	)
 	return i, err
 }
@@ -80,7 +84,7 @@ func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) e
 }
 
 const getMessage = `-- name: GetMessage :one
-SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider
+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
 FROM messages
 WHERE id = ? LIMIT 1
 `
@@ -98,12 +102,13 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
 		&i.UpdatedAt,
 		&i.FinishedAt,
 		&i.Provider,
+		&i.IsSummaryMessage,
 	)
 	return i, err
 }
 
 const listMessagesBySession = `-- name: ListMessagesBySession :many
-SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider
+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
 FROM messages
 WHERE session_id = ?
 ORDER BY created_at ASC
@@ -128,6 +133,7 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
 			&i.UpdatedAt,
 			&i.FinishedAt,
 			&i.Provider,
+			&i.IsSummaryMessage,
 		); err != nil {
 			return nil, err
 		}
diff --git a/internal/db/migrations/20250810000000_add_is_summary_message.sql b/internal/db/migrations/20250810000000_add_is_summary_message.sql
new file mode 100644
index 00000000..0c400b5e
--- /dev/null
+++ b/internal/db/migrations/20250810000000_add_is_summary_message.sql
@@ -0,0 +1,5 @@
+-- +goose Up
+ALTER TABLE messages ADD COLUMN is_summary_message INTEGER DEFAULT 0 NOT NULL;
+
+-- +goose Down
+ALTER TABLE messages DROP COLUMN is_summary_message;
diff --git a/internal/db/models.go b/internal/db/models.go
index ec3e6e10..ddced85d 100644
--- a/internal/db/models.go
+++ b/internal/db/models.go
@@ -1,6 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
 // versions:
-//   sqlc v1.29.0
+//   sqlc v1.30.0
 
 package db
 
@@ -19,15 +19,16 @@ type File struct {
 }
 
 type Message struct {
-	ID         string         `json:"id"`
-	SessionID  string         `json:"session_id"`
-	Role       string         `json:"role"`
-	Parts      string         `json:"parts"`
-	Model      sql.NullString `json:"model"`
-	CreatedAt  int64          `json:"created_at"`
-	UpdatedAt  int64          `json:"updated_at"`
-	FinishedAt sql.NullInt64  `json:"finished_at"`
-	Provider   sql.NullString `json:"provider"`
+	ID               string         `json:"id"`
+	SessionID        string         `json:"session_id"`
+	Role             string         `json:"role"`
+	Parts            string         `json:"parts"`
+	Model            sql.NullString `json:"model"`
+	CreatedAt        int64          `json:"created_at"`
+	UpdatedAt        int64          `json:"updated_at"`
+	FinishedAt       sql.NullInt64  `json:"finished_at"`
+	Provider         sql.NullString `json:"provider"`
+	IsSummaryMessage int64          `json:"is_summary_message"`
 }
 
 type Session struct {
diff --git a/internal/db/querier.go b/internal/db/querier.go
index 47213727..0978eb2c 100644
--- a/internal/db/querier.go
+++ b/internal/db/querier.go
@@ -1,6 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
 // versions:
-//   sqlc v1.29.0
+//   sqlc v1.30.0
 
 package db
 
diff --git a/internal/db/sessions.sql.go b/internal/db/sessions.sql.go
index 76ef6480..99d31fa2 100644
--- a/internal/db/sessions.sql.go
+++ b/internal/db/sessions.sql.go
@@ -1,6 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
 // versions:
-//   sqlc v1.29.0
+//   sqlc v1.30.0
 // source: sessions.sql
 
 package db
diff --git a/internal/db/sql/messages.sql b/internal/db/sql/messages.sql
index ea946177..fc66b78c 100644
--- a/internal/db/sql/messages.sql
+++ b/internal/db/sql/messages.sql
@@ -17,10 +17,11 @@ INSERT INTO messages (
     parts,
     model,
     provider,
+    is_summary_message,
     created_at,
     updated_at
 ) VALUES (
-    ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
+    ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
 )
 RETURNING *;
 
diff --git a/internal/filepathext/filepath.go b/internal/filepathext/filepath.go
new file mode 100644
index 00000000..08aac0c7
--- /dev/null
+++ b/internal/filepathext/filepath.go
@@ -0,0 +1,27 @@
+package filepathext
+
+import (
+	"path/filepath"
+	"runtime"
+	"strings"
+)
+
+// SmartJoin joins two paths, treating the second path as absolute if it is an
+// absolute path.
+func SmartJoin(one, two string) string {
+	if SmartIsAbs(two) {
+		return two
+	}
+	return filepath.Join(one, two)
+}
+
+// SmartIsAbs checks if a path is absolute, considering both OS-specific and
+// Unix-style paths.
+func SmartIsAbs(path string) bool {
+	switch runtime.GOOS {
+	case "windows":
+		return filepath.IsAbs(path) || strings.HasPrefix(filepath.ToSlash(path), "/")
+	default:
+		return filepath.IsAbs(path)
+	}
+}
diff --git a/internal/format/spinner.go b/internal/format/spinner.go
index 69e443d0..53d48dbb 100644
--- a/internal/format/spinner.go
+++ b/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"
 )
 
@@ -23,8 +22,8 @@ type model struct {
 	anim   *anim.Anim
 }
 
-func (m model) Init() tea.Cmd { return m.anim.Init() }
-func (m model) View() string  { return m.anim.View() }
+func (m model) Init() tea.Cmd  { return m.anim.Init() }
+func (m model) View() tea.View { return tea.NewView(m.anim.View()) }
 
 // Update implements tea.Model.
 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -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),
 	}
 }
diff --git a/internal/home/home.go b/internal/home/home.go
index 74ab5594..e4464923 100644
--- a/internal/home/home.go
+++ b/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)
 }
diff --git a/internal/llm/agent/agent-tool.go b/internal/llm/agent/agent-tool.go
deleted file mode 100644
index ae15c586..00000000
--- a/internal/llm/agent/agent-tool.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package agent
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/session"
-)
-
-type agentTool struct {
-	agent    Service
-	sessions session.Service
-	messages message.Service
-}
-
-const (
-	AgentToolName = "agent"
-)
-
-type AgentParams struct {
-	Prompt string `json:"prompt"`
-}
-
-func (b *agentTool) Name() string {
-	return AgentToolName
-}
-
-func (b *agentTool) Info() tools.ToolInfo {
-	return tools.ToolInfo{
-		Name:        AgentToolName,
-		Description: "Launch a new agent that has access to the following tools: GlobTool, GrepTool, LS, View. When you are searching for a keyword or file and are not confident that you will find the right match on the first try, use the Agent tool to perform the search for you. For example:\n\n- If you are searching for a keyword like \"config\" or \"logger\", or for questions like \"which file does X?\", the Agent tool is strongly recommended\n- If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool, to find the match more quickly\n- If you are searching for a specific class definition like \"class Foo\", use the GlobTool tool instead, to find the match more quickly\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses\n2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.\n4. The agent's outputs should generally be trusted\n5. IMPORTANT: The agent can not use Bash, Replace, Edit, so can not modify files. If you want to use these tools, use them directly instead of going through the agent.",
-		Parameters: map[string]any{
-			"prompt": map[string]any{
-				"type":        "string",
-				"description": "The task for the agent to perform",
-			},
-		},
-		Required: []string{"prompt"},
-	}
-}
-
-func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolResponse, error) {
-	var params AgentParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return tools.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-	if params.Prompt == "" {
-		return tools.NewTextErrorResponse("prompt is required"), nil
-	}
-
-	sessionID, messageID := tools.GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required")
-	}
-
-	session, err := b.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session")
-	if err != nil {
-		return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
-	}
-
-	done, err := b.agent.Run(ctx, session.ID, params.Prompt)
-	if err != nil {
-		return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
-	}
-	result := <-done
-	if result.Error != nil {
-		return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Error)
-	}
-
-	response := result.Message
-	if response.Role != message.Assistant {
-		return tools.NewTextErrorResponse("no response"), nil
-	}
-
-	updatedSession, err := b.sessions.Get(ctx, session.ID)
-	if err != nil {
-		return tools.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
-	}
-	parentSession, err := b.sessions.Get(ctx, sessionID)
-	if err != nil {
-		return tools.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err)
-	}
-
-	parentSession.Cost += updatedSession.Cost
-
-	_, err = b.sessions.Save(ctx, parentSession)
-	if err != nil {
-		return tools.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
-	}
-	return tools.NewTextResponse(response.Content().String()), nil
-}
-
-func NewAgentTool(
-	agent Service,
-	sessions session.Service,
-	messages message.Service,
-) tools.BaseTool {
-	return &agentTool{
-		sessions: sessions,
-		messages: messages,
-		agent:    agent,
-	}
-}
diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go
deleted file mode 100644
index 6825da22..00000000
--- a/internal/llm/agent/agent.go
+++ /dev/null
@@ -1,1138 +0,0 @@
-// Package agent contains the implementation of the AI agent service.
-package agent
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"log/slog"
-	"maps"
-	"slices"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/event"
-	"github.com/charmbracelet/crush/internal/history"
-	"github.com/charmbracelet/crush/internal/llm/prompt"
-	"github.com/charmbracelet/crush/internal/llm/provider"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/log"
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/permission"
-	"github.com/charmbracelet/crush/internal/pubsub"
-	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/shell"
-)
-
-type AgentEventType string
-
-const (
-	AgentEventTypeError     AgentEventType = "error"
-	AgentEventTypeResponse  AgentEventType = "response"
-	AgentEventTypeSummarize AgentEventType = "summarize"
-)
-
-type AgentEvent struct {
-	Type    AgentEventType
-	Message message.Message
-	Error   error
-
-	// When summarizing
-	SessionID string
-	Progress  string
-	Done      bool
-}
-
-type Service interface {
-	pubsub.Suscriber[AgentEvent]
-	Model() catwalk.Model
-	Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
-	Cancel(sessionID string)
-	CancelAll()
-	IsSessionBusy(sessionID string) bool
-	IsBusy() bool
-	Summarize(ctx context.Context, sessionID string) error
-	UpdateModel() error
-	QueuedPrompts(sessionID string) int
-	ClearQueue(sessionID string)
-}
-
-type agent struct {
-	*pubsub.Broker[AgentEvent]
-	agentCfg    config.Agent
-	sessions    session.Service
-	messages    message.Service
-	permissions permission.Service
-	baseTools   *csync.Map[string, tools.BaseTool]
-	mcpTools    *csync.Map[string, tools.BaseTool]
-	lspClients  *csync.Map[string, *lsp.Client]
-
-	// We need this to be able to update it when model changes
-	agentToolFn  func() (tools.BaseTool, error)
-	cleanupFuncs []func()
-
-	provider   provider.Provider
-	providerID string
-
-	titleProvider       provider.Provider
-	summarizeProvider   provider.Provider
-	summarizeProviderID string
-
-	activeRequests *csync.Map[string, context.CancelFunc]
-	promptQueue    *csync.Map[string, []string]
-}
-
-var agentPromptMap = map[string]prompt.PromptID{
-	"coder": prompt.PromptCoder,
-	"task":  prompt.PromptTask,
-}
-
-func NewAgent(
-	ctx context.Context,
-	agentCfg config.Agent,
-	// These services are needed in the tools
-	permissions permission.Service,
-	sessions session.Service,
-	messages message.Service,
-	history history.Service,
-	lspClients *csync.Map[string, *lsp.Client],
-) (Service, error) {
-	cfg := config.Get()
-
-	var agentToolFn func() (tools.BaseTool, error)
-	if agentCfg.ID == "coder" && slices.Contains(agentCfg.AllowedTools, AgentToolName) {
-		agentToolFn = func() (tools.BaseTool, error) {
-			taskAgentCfg := config.Get().Agents["task"]
-			if taskAgentCfg.ID == "" {
-				return nil, fmt.Errorf("task agent not found in config")
-			}
-			taskAgent, err := NewAgent(ctx, taskAgentCfg, permissions, sessions, messages, history, lspClients)
-			if err != nil {
-				return nil, fmt.Errorf("failed to create task agent: %w", err)
-			}
-			return NewAgentTool(taskAgent, sessions, messages), nil
-		}
-	}
-
-	providerCfg := config.Get().GetProviderForModel(agentCfg.Model)
-	if providerCfg == nil {
-		return nil, fmt.Errorf("provider for agent %s not found in config", agentCfg.Name)
-	}
-	model := config.Get().GetModelByType(agentCfg.Model)
-
-	if model == nil {
-		return nil, fmt.Errorf("model not found for agent %s", agentCfg.Name)
-	}
-
-	promptID := agentPromptMap[agentCfg.ID]
-	if promptID == "" {
-		promptID = prompt.PromptDefault
-	}
-	opts := []provider.ProviderClientOption{
-		provider.WithModel(agentCfg.Model),
-		provider.WithSystemMessage(prompt.GetPrompt(promptID, providerCfg.ID, config.Get().Options.ContextPaths...)),
-	}
-	agentProvider, err := provider.NewProvider(*providerCfg, opts...)
-	if err != nil {
-		return nil, err
-	}
-
-	smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
-	var smallModelProviderCfg *config.ProviderConfig
-	if smallModelCfg.Provider == providerCfg.ID {
-		smallModelProviderCfg = providerCfg
-	} else {
-		smallModelProviderCfg = cfg.GetProviderForModel(config.SelectedModelTypeSmall)
-
-		if smallModelProviderCfg.ID == "" {
-			return nil, fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
-		}
-	}
-	smallModel := cfg.GetModelByType(config.SelectedModelTypeSmall)
-	if smallModel.ID == "" {
-		return nil, fmt.Errorf("model %s not found in provider %s", smallModelCfg.Model, smallModelProviderCfg.ID)
-	}
-
-	titleOpts := []provider.ProviderClientOption{
-		provider.WithModel(config.SelectedModelTypeSmall),
-		provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
-	}
-	titleProvider, err := provider.NewProvider(*smallModelProviderCfg, titleOpts...)
-	if err != nil {
-		return nil, err
-	}
-
-	summarizeOpts := []provider.ProviderClientOption{
-		provider.WithModel(config.SelectedModelTypeLarge),
-		provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, providerCfg.ID)),
-	}
-	summarizeProvider, err := provider.NewProvider(*providerCfg, summarizeOpts...)
-	if err != nil {
-		return nil, err
-	}
-
-	baseToolsFn := func() map[string]tools.BaseTool {
-		slog.Debug("Initializing agent base tools", "agent", agentCfg.ID)
-		defer func() {
-			slog.Debug("Initialized agent base tools", "agent", agentCfg.ID)
-		}()
-
-		// Base tools available to all agents
-		cwd := cfg.WorkingDir()
-		result := make(map[string]tools.BaseTool)
-		for _, tool := range []tools.BaseTool{
-			tools.NewBashTool(permissions, cwd, cfg.Options.Attribution),
-			tools.NewDownloadTool(permissions, cwd),
-			tools.NewEditTool(lspClients, permissions, history, cwd),
-			tools.NewMultiEditTool(lspClients, permissions, history, cwd),
-			tools.NewFetchTool(permissions, cwd),
-			tools.NewGlobTool(cwd),
-			tools.NewGrepTool(cwd),
-			tools.NewLsTool(permissions, cwd),
-			tools.NewSourcegraphTool(),
-			tools.NewViewTool(lspClients, permissions, cwd),
-			tools.NewWriteTool(lspClients, permissions, history, cwd),
-		} {
-			result[tool.Name()] = tool
-		}
-		return result
-	}
-	mcpToolsFn := func() map[string]tools.BaseTool {
-		slog.Debug("Initializing agent mcp tools", "agent", agentCfg.ID)
-		defer func() {
-			slog.Debug("Initialized agent mcp tools", "agent", agentCfg.ID)
-		}()
-
-		mcpToolsOnce.Do(func() {
-			doGetMCPTools(ctx, permissions, cfg)
-		})
-
-		return maps.Collect(mcpTools.Seq2())
-	}
-
-	a := &agent{
-		Broker:              pubsub.NewBroker[AgentEvent](),
-		agentCfg:            agentCfg,
-		provider:            agentProvider,
-		providerID:          string(providerCfg.ID),
-		messages:            messages,
-		sessions:            sessions,
-		titleProvider:       titleProvider,
-		summarizeProvider:   summarizeProvider,
-		summarizeProviderID: string(providerCfg.ID),
-		agentToolFn:         agentToolFn,
-		activeRequests:      csync.NewMap[string, context.CancelFunc](),
-		mcpTools:            csync.NewLazyMap(mcpToolsFn),
-		baseTools:           csync.NewLazyMap(baseToolsFn),
-		promptQueue:         csync.NewMap[string, []string](),
-		permissions:         permissions,
-		lspClients:          lspClients,
-	}
-	a.setupEvents(ctx)
-	return a, nil
-}
-
-func (a *agent) Model() catwalk.Model {
-	return *config.Get().GetModelByType(a.agentCfg.Model)
-}
-
-func (a *agent) Cancel(sessionID string) {
-	// 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
-	if cancel, ok := a.activeRequests.Take(sessionID + "-summarize"); ok && cancel != nil {
-		slog.Info("Summarize cancellation initiated", "session_id", sessionID)
-		cancel()
-	}
-
-	if a.QueuedPrompts(sessionID) > 0 {
-		slog.Info("Clearing queued prompts", "session_id", sessionID)
-		a.promptQueue.Del(sessionID)
-	}
-}
-
-func (a *agent) IsBusy() bool {
-	var busy bool
-	for cancelFunc := range a.activeRequests.Seq() {
-		if cancelFunc != nil {
-			busy = true
-			break
-		}
-	}
-	return busy
-}
-
-func (a *agent) IsSessionBusy(sessionID string) bool {
-	_, busy := a.activeRequests.Get(sessionID)
-	return busy
-}
-
-func (a *agent) QueuedPrompts(sessionID string) int {
-	l, ok := a.promptQueue.Get(sessionID)
-	if !ok {
-		return 0
-	}
-	return len(l)
-}
-
-func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
-	if content == "" {
-		return nil
-	}
-	if a.titleProvider == nil {
-		return nil
-	}
-	session, err := a.sessions.Get(ctx, sessionID)
-	if err != nil {
-		return err
-	}
-	parts := []message.ContentPart{message.TextContent{
-		Text: fmt.Sprintf("Generate a concise title for the following content:\n\n%s", content),
-	}}
-
-	// Use streaming approach like summarization
-	response := a.titleProvider.StreamResponse(
-		ctx,
-		[]message.Message{
-			{
-				Role:  message.User,
-				Parts: parts,
-			},
-		},
-		nil,
-	)
-
-	var finalResponse *provider.ProviderResponse
-	for r := range response {
-		if r.Error != nil {
-			return r.Error
-		}
-		finalResponse = r.Response
-	}
-
-	if finalResponse == nil {
-		return fmt.Errorf("no response received from title provider")
-	}
-
-	title := strings.ReplaceAll(finalResponse.Content, "\n", " ")
-
-	if idx := strings.Index(title, "</think>"); idx > 0 {
-		title = title[idx+len("</think>"):]
-	}
-
-	title = strings.TrimSpace(title)
-	if title == "" {
-		return nil
-	}
-
-	session.Title = title
-	_, err = a.sessions.Save(ctx, session)
-	return err
-}
-
-func (a *agent) err(err error) AgentEvent {
-	return AgentEvent{
-		Type:  AgentEventTypeError,
-		Error: err,
-	}
-}
-
-func (a *agent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) {
-	if !a.Model().SupportsImages && attachments != nil {
-		attachments = nil
-	}
-	events := make(chan AgentEvent, 1)
-	if a.IsSessionBusy(sessionID) {
-		existing, ok := a.promptQueue.Get(sessionID)
-		if !ok {
-			existing = []string{}
-		}
-		existing = append(existing, content)
-		a.promptQueue.Set(sessionID, existing)
-		return nil, nil
-	}
-
-	genCtx, cancel := context.WithCancel(ctx)
-	a.activeRequests.Set(sessionID, cancel)
-	startTime := time.Now()
-
-	go func() {
-		slog.Debug("Request started", "sessionID", sessionID)
-		defer log.RecoverPanic("agent.Run", func() {
-			events <- a.err(fmt.Errorf("panic while running the agent"))
-		})
-		var attachmentParts []message.ContentPart
-		for _, attachment := range attachments {
-			attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
-		}
-		result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
-		if result.Error != nil {
-			if isCancelledErr(result.Error) {
-				slog.Error("Request canceled", "sessionID", sessionID)
-			} else {
-				slog.Error("Request errored", "sessionID", sessionID, "error", result.Error.Error())
-				event.Error(result.Error)
-			}
-		} else {
-			slog.Debug("Request completed", "sessionID", sessionID)
-		}
-		a.eventPromptResponded(sessionID, time.Since(startTime).Truncate(time.Second))
-		a.activeRequests.Del(sessionID)
-		cancel()
-		a.Publish(pubsub.CreatedEvent, result)
-		events <- result
-		close(events)
-	}()
-	a.eventPromptSent(sessionID)
-	return events, nil
-}
-
-func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
-	cfg := config.Get()
-	// List existing messages; if none, start title generation asynchronously.
-	msgs, err := a.messages.List(ctx, sessionID)
-	if err != nil {
-		return a.err(fmt.Errorf("failed to list messages: %w", err))
-	}
-	if len(msgs) == 0 {
-		go func() {
-			defer log.RecoverPanic("agent.Run", func() {
-				slog.Error("panic while generating title")
-			})
-			titleErr := a.generateTitle(ctx, sessionID, content)
-			if titleErr != nil && !errors.Is(titleErr, context.Canceled) && !errors.Is(titleErr, context.DeadlineExceeded) {
-				slog.Error("failed to generate title", "error", titleErr)
-			}
-		}()
-	}
-	session, err := a.sessions.Get(ctx, sessionID)
-	if err != nil {
-		return a.err(fmt.Errorf("failed to get session: %w", err))
-	}
-	if session.SummaryMessageID != "" {
-		summaryMsgInex := -1
-		for i, msg := range msgs {
-			if msg.ID == session.SummaryMessageID {
-				summaryMsgInex = i
-				break
-			}
-		}
-		if summaryMsgInex != -1 {
-			msgs = msgs[summaryMsgInex:]
-			msgs[0].Role = message.User
-		}
-	}
-
-	userMsg, err := a.createUserMessage(ctx, sessionID, content, attachmentParts)
-	if err != nil {
-		return a.err(fmt.Errorf("failed to create user message: %w", err))
-	}
-	// Append the new user message to the conversation history.
-	msgHistory := append(msgs, userMsg)
-
-	for {
-		// Check for cancellation before each iteration
-		select {
-		case <-ctx.Done():
-			return a.err(ctx.Err())
-		default:
-			// Continue processing
-		}
-		agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
-		if err != nil {
-			if errors.Is(err, context.Canceled) {
-				agentMessage.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
-				a.messages.Update(context.Background(), agentMessage)
-				return a.err(ErrRequestCancelled)
-			}
-			return a.err(fmt.Errorf("failed to process events: %w", err))
-		}
-		if cfg.Options.Debug {
-			slog.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
-		}
-		if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
-			// We are not done, we need to respond with the tool response
-			msgHistory = append(msgHistory, agentMessage, *toolResults)
-			// If there are queued prompts, process the next one
-			nextPrompt, ok := a.promptQueue.Take(sessionID)
-			if ok {
-				for _, prompt := range nextPrompt {
-					// Create a new user message for the queued prompt
-					userMsg, err := a.createUserMessage(ctx, sessionID, prompt, nil)
-					if err != nil {
-						return a.err(fmt.Errorf("failed to create user message for queued prompt: %w", err))
-					}
-					// Append the new user message to the conversation history
-					msgHistory = append(msgHistory, userMsg)
-				}
-			}
-
-			continue
-		} else if agentMessage.FinishReason() == message.FinishReasonEndTurn {
-			queuePrompts, ok := a.promptQueue.Take(sessionID)
-			if ok {
-				for _, prompt := range queuePrompts {
-					if prompt == "" {
-						continue
-					}
-					userMsg, err := a.createUserMessage(ctx, sessionID, prompt, nil)
-					if err != nil {
-						return a.err(fmt.Errorf("failed to create user message for queued prompt: %w", err))
-					}
-					msgHistory = append(msgHistory, userMsg)
-				}
-				continue
-			}
-		}
-		if agentMessage.FinishReason() == "" {
-			// Kujtim: could not track down where this is happening but this means its cancelled
-			agentMessage.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
-			_ = a.messages.Update(context.Background(), agentMessage)
-			return a.err(ErrRequestCancelled)
-		}
-		return AgentEvent{
-			Type:    AgentEventTypeResponse,
-			Message: agentMessage,
-			Done:    true,
-		}
-	}
-}
-
-func (a *agent) createUserMessage(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) (message.Message, error) {
-	parts := []message.ContentPart{message.TextContent{Text: content}}
-	parts = append(parts, attachmentParts...)
-	return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
-		Role:  message.User,
-		Parts: parts,
-	})
-}
-
-func (a *agent) getAllTools() ([]tools.BaseTool, error) {
-	var allTools []tools.BaseTool
-	for tool := range a.baseTools.Seq() {
-		if a.agentCfg.AllowedTools == nil || slices.Contains(a.agentCfg.AllowedTools, tool.Name()) {
-			allTools = append(allTools, tool)
-		}
-	}
-	if a.agentCfg.ID == "coder" {
-		allTools = slices.AppendSeq(allTools, a.mcpTools.Seq())
-		if a.lspClients.Len() > 0 {
-			allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients), tools.NewReferencesTool(a.lspClients))
-		}
-	}
-	if a.agentToolFn != nil {
-		agentTool, agentToolErr := a.agentToolFn()
-		if agentToolErr != nil {
-			return nil, agentToolErr
-		}
-		allTools = append(allTools, agentTool)
-	}
-
-	slices.SortFunc(allTools, func(a, b tools.BaseTool) int {
-		return strings.Compare(a.Name(), b.Name())
-	})
-	return allTools, nil
-}
-
-func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
-	ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
-
-	// Create the assistant message first so the spinner shows immediately
-	assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
-		Role:     message.Assistant,
-		Parts:    []message.ContentPart{},
-		Model:    a.Model().ID,
-		Provider: a.providerID,
-	})
-	if err != nil {
-		return assistantMsg, nil, fmt.Errorf("failed to create assistant message: %w", err)
-	}
-
-	allTools, toolsErr := a.getAllTools()
-	if toolsErr != nil {
-		return assistantMsg, nil, toolsErr
-	}
-	// Now collect tools (which may block on MCP initialization)
-	eventChan := a.provider.StreamResponse(ctx, msgHistory, allTools)
-
-	// Add the session and message ID into the context if needed by tools.
-	ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
-
-loop:
-	for {
-		select {
-		case event, ok := <-eventChan:
-			if !ok {
-				break loop
-			}
-			if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
-				if errors.Is(processErr, context.Canceled) {
-					a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
-				} else {
-					a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error())
-				}
-				return assistantMsg, nil, processErr
-			}
-		case <-ctx.Done():
-			a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
-			return assistantMsg, nil, ctx.Err()
-		}
-	}
-
-	toolResults := make([]message.ToolResult, len(assistantMsg.ToolCalls()))
-	toolCalls := assistantMsg.ToolCalls()
-	for i, toolCall := range toolCalls {
-		select {
-		case <-ctx.Done():
-			a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
-			// Make all future tool calls cancelled
-			for j := i; j < len(toolCalls); j++ {
-				toolResults[j] = message.ToolResult{
-					ToolCallID: toolCalls[j].ID,
-					Content:    "Tool execution canceled by user",
-					IsError:    true,
-				}
-			}
-			goto out
-		default:
-			// Continue processing
-			var tool tools.BaseTool
-			allTools, _ = a.getAllTools()
-			for _, availableTool := range allTools {
-				if availableTool.Info().Name == toolCall.Name {
-					tool = availableTool
-					break
-				}
-			}
-
-			// Tool not found
-			if tool == nil {
-				toolResults[i] = message.ToolResult{
-					ToolCallID: toolCall.ID,
-					Content:    fmt.Sprintf("Tool not found: %s", toolCall.Name),
-					IsError:    true,
-				}
-				continue
-			}
-
-			// Run tool in goroutine to allow cancellation
-			type toolExecResult struct {
-				response tools.ToolResponse
-				err      error
-			}
-			resultChan := make(chan toolExecResult, 1)
-
-			go func() {
-				response, err := tool.Run(ctx, tools.ToolCall{
-					ID:    toolCall.ID,
-					Name:  toolCall.Name,
-					Input: toolCall.Input,
-				})
-				resultChan <- toolExecResult{response: response, err: err}
-			}()
-
-			var toolResponse tools.ToolResponse
-			var toolErr error
-
-			select {
-			case <-ctx.Done():
-				a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
-				// Mark remaining tool calls as cancelled
-				for j := i; j < len(toolCalls); j++ {
-					toolResults[j] = message.ToolResult{
-						ToolCallID: toolCalls[j].ID,
-						Content:    "Tool execution canceled by user",
-						IsError:    true,
-					}
-				}
-				goto out
-			case result := <-resultChan:
-				toolResponse = result.response
-				toolErr = result.err
-			}
-
-			if toolErr != nil {
-				slog.Error("Tool execution error", "toolCall", toolCall.ID, "error", toolErr)
-				if errors.Is(toolErr, permission.ErrorPermissionDenied) {
-					toolResults[i] = message.ToolResult{
-						ToolCallID: toolCall.ID,
-						Content:    "Permission denied",
-						IsError:    true,
-					}
-					for j := i + 1; j < len(toolCalls); j++ {
-						toolResults[j] = message.ToolResult{
-							ToolCallID: toolCalls[j].ID,
-							Content:    "Tool execution canceled by user",
-							IsError:    true,
-						}
-					}
-					a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied, "Permission denied", "")
-					break
-				}
-			}
-			toolResults[i] = message.ToolResult{
-				ToolCallID: toolCall.ID,
-				Content:    toolResponse.Content,
-				Metadata:   toolResponse.Metadata,
-				IsError:    toolResponse.IsError,
-			}
-		}
-	}
-out:
-	if len(toolResults) == 0 {
-		return assistantMsg, nil, nil
-	}
-	parts := make([]message.ContentPart, 0)
-	for _, tr := range toolResults {
-		parts = append(parts, tr)
-	}
-	msg, err := a.messages.Create(context.Background(), assistantMsg.SessionID, message.CreateMessageParams{
-		Role:     message.Tool,
-		Parts:    parts,
-		Provider: a.providerID,
-	})
-	if err != nil {
-		return assistantMsg, nil, fmt.Errorf("failed to create cancelled tool message: %w", err)
-	}
-
-	return assistantMsg, &msg, err
-}
-
-func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReason message.FinishReason, message, details string) {
-	msg.AddFinish(finishReason, message, details)
-	_ = a.messages.Update(ctx, *msg)
-}
-
-func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg *message.Message, event provider.ProviderEvent) error {
-	select {
-	case <-ctx.Done():
-		return ctx.Err()
-	default:
-		// Continue processing.
-	}
-
-	switch event.Type {
-	case provider.EventThinkingDelta:
-		assistantMsg.AppendReasoningContent(event.Thinking)
-		return a.messages.Update(ctx, *assistantMsg)
-	case provider.EventSignatureDelta:
-		assistantMsg.AppendReasoningSignature(event.Signature)
-		return a.messages.Update(ctx, *assistantMsg)
-	case provider.EventContentDelta:
-		assistantMsg.FinishThinking()
-		assistantMsg.AppendContent(event.Content)
-		return a.messages.Update(ctx, *assistantMsg)
-	case provider.EventToolUseStart:
-		assistantMsg.FinishThinking()
-		slog.Info("Tool call started", "toolCall", event.ToolCall)
-		assistantMsg.AddToolCall(*event.ToolCall)
-		return a.messages.Update(ctx, *assistantMsg)
-	case provider.EventToolUseDelta:
-		assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
-		return a.messages.Update(ctx, *assistantMsg)
-	case provider.EventToolUseStop:
-		slog.Info("Finished tool call", "toolCall", event.ToolCall)
-		assistantMsg.FinishToolCall(event.ToolCall.ID)
-		return a.messages.Update(ctx, *assistantMsg)
-	case provider.EventError:
-		return event.Error
-	case provider.EventComplete:
-		assistantMsg.FinishThinking()
-		assistantMsg.SetToolCalls(event.Response.ToolCalls)
-		assistantMsg.AddFinish(event.Response.FinishReason, "", "")
-		if err := a.messages.Update(ctx, *assistantMsg); err != nil {
-			return fmt.Errorf("failed to update message: %w", err)
-		}
-		return a.trackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
-	}
-
-	return nil
-}
-
-func (a *agent) trackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
-	sess, err := a.sessions.Get(ctx, sessionID)
-	if err != nil {
-		return fmt.Errorf("failed to get session: %w", err)
-	}
-
-	cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
-		model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
-		model.CostPer1MIn/1e6*float64(usage.InputTokens) +
-		model.CostPer1MOut/1e6*float64(usage.OutputTokens)
-
-	a.eventTokensUsed(sessionID, usage, cost)
-
-	sess.Cost += cost
-	sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
-	sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
-
-	_, err = a.sessions.Save(ctx, sess)
-	if err != nil {
-		return fmt.Errorf("failed to save session: %w", err)
-	}
-	return nil
-}
-
-func (a *agent) Summarize(ctx context.Context, sessionID string) error {
-	if a.summarizeProvider == nil {
-		return fmt.Errorf("summarize provider not available")
-	}
-
-	// Check if session is busy
-	if a.IsSessionBusy(sessionID) {
-		return ErrSessionBusy
-	}
-
-	// Create a new context with cancellation
-	summarizeCtx, cancel := context.WithCancel(ctx)
-
-	// Store the cancel function in activeRequests to allow cancellation
-	a.activeRequests.Set(sessionID+"-summarize", cancel)
-
-	go func() {
-		defer a.activeRequests.Del(sessionID + "-summarize")
-		defer cancel()
-		event := AgentEvent{
-			Type:     AgentEventTypeSummarize,
-			Progress: "Starting summarization...",
-		}
-
-		a.Publish(pubsub.CreatedEvent, event)
-		// Get all messages from the session
-		msgs, err := a.messages.List(summarizeCtx, sessionID)
-		if err != nil {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("failed to list messages: %w", err),
-				Done:  true,
-			}
-			a.Publish(pubsub.CreatedEvent, event)
-			return
-		}
-		summarizeCtx = context.WithValue(summarizeCtx, tools.SessionIDContextKey, sessionID)
-
-		if len(msgs) == 0 {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("no messages to summarize"),
-				Done:  true,
-			}
-			a.Publish(pubsub.CreatedEvent, event)
-			return
-		}
-
-		event = AgentEvent{
-			Type:     AgentEventTypeSummarize,
-			Progress: "Analyzing conversation...",
-		}
-		a.Publish(pubsub.CreatedEvent, event)
-
-		// Add a system message to guide the summarization
-		summarizePrompt := "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next."
-
-		// Create a new message with the summarize prompt
-		promptMsg := message.Message{
-			Role:  message.User,
-			Parts: []message.ContentPart{message.TextContent{Text: summarizePrompt}},
-		}
-
-		// Append the prompt to the messages
-		msgsWithPrompt := append(msgs, promptMsg)
-
-		event = AgentEvent{
-			Type:     AgentEventTypeSummarize,
-			Progress: "Generating summary...",
-		}
-
-		a.Publish(pubsub.CreatedEvent, event)
-
-		// Send the messages to the summarize provider
-		response := a.summarizeProvider.StreamResponse(
-			summarizeCtx,
-			msgsWithPrompt,
-			nil,
-		)
-		var finalResponse *provider.ProviderResponse
-		for r := range response {
-			if r.Error != nil {
-				event = AgentEvent{
-					Type:  AgentEventTypeError,
-					Error: fmt.Errorf("failed to summarize: %w", r.Error),
-					Done:  true,
-				}
-				a.Publish(pubsub.CreatedEvent, event)
-				return
-			}
-			finalResponse = r.Response
-		}
-
-		summary := strings.TrimSpace(finalResponse.Content)
-		if summary == "" {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("empty summary returned"),
-				Done:  true,
-			}
-			a.Publish(pubsub.CreatedEvent, event)
-			return
-		}
-		shell := shell.GetPersistentShell(config.Get().WorkingDir())
-		summary += "\n\n**Current working directory of the persistent shell**\n\n" + shell.GetWorkingDir()
-		event = AgentEvent{
-			Type:     AgentEventTypeSummarize,
-			Progress: "Creating new session...",
-		}
-
-		a.Publish(pubsub.CreatedEvent, event)
-		oldSession, err := a.sessions.Get(summarizeCtx, sessionID)
-		if err != nil {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("failed to get session: %w", err),
-				Done:  true,
-			}
-
-			a.Publish(pubsub.CreatedEvent, event)
-			return
-		}
-		// Create a message in the new session with the summary
-		msg, err := a.messages.Create(summarizeCtx, oldSession.ID, message.CreateMessageParams{
-			Role: message.Assistant,
-			Parts: []message.ContentPart{
-				message.TextContent{Text: summary},
-				message.Finish{
-					Reason: message.FinishReasonEndTurn,
-					Time:   time.Now().Unix(),
-				},
-			},
-			Model:    a.summarizeProvider.Model().ID,
-			Provider: a.summarizeProviderID,
-		})
-		if err != nil {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("failed to create summary message: %w", err),
-				Done:  true,
-			}
-
-			a.Publish(pubsub.CreatedEvent, event)
-			return
-		}
-		oldSession.SummaryMessageID = msg.ID
-		oldSession.CompletionTokens = finalResponse.Usage.OutputTokens
-		oldSession.PromptTokens = 0
-		model := a.summarizeProvider.Model()
-		usage := finalResponse.Usage
-		cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
-			model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
-			model.CostPer1MIn/1e6*float64(usage.InputTokens) +
-			model.CostPer1MOut/1e6*float64(usage.OutputTokens)
-		oldSession.Cost += cost
-		_, err = a.sessions.Save(summarizeCtx, oldSession)
-		if err != nil {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("failed to save session: %w", err),
-				Done:  true,
-			}
-			a.Publish(pubsub.CreatedEvent, event)
-		}
-
-		event = AgentEvent{
-			Type:      AgentEventTypeSummarize,
-			SessionID: oldSession.ID,
-			Progress:  "Summary complete",
-			Done:      true,
-		}
-		a.Publish(pubsub.CreatedEvent, event)
-		// Send final success event with the new session ID
-	}()
-
-	return nil
-}
-
-func (a *agent) ClearQueue(sessionID string) {
-	if a.QueuedPrompts(sessionID) > 0 {
-		slog.Info("Clearing queued prompts", "session_id", sessionID)
-		a.promptQueue.Del(sessionID)
-	}
-}
-
-func (a *agent) CancelAll() {
-	if !a.IsBusy() {
-		return
-	}
-	for key := range a.activeRequests.Seq2() {
-		a.Cancel(key) // key is sessionID
-	}
-
-	for _, cleanup := range a.cleanupFuncs {
-		if cleanup != nil {
-			cleanup()
-		}
-	}
-
-	timeout := time.After(5 * time.Second)
-	for a.IsBusy() {
-		select {
-		case <-timeout:
-			return
-		default:
-			time.Sleep(200 * time.Millisecond)
-		}
-	}
-}
-
-func (a *agent) UpdateModel() error {
-	cfg := config.Get()
-
-	// Get current provider configuration
-	currentProviderCfg := cfg.GetProviderForModel(a.agentCfg.Model)
-	if currentProviderCfg == nil || currentProviderCfg.ID == "" {
-		return fmt.Errorf("provider for agent %s not found in config", a.agentCfg.Name)
-	}
-
-	// Check if provider has changed
-	if string(currentProviderCfg.ID) != a.providerID {
-		// Provider changed, need to recreate the main provider
-		model := cfg.GetModelByType(a.agentCfg.Model)
-		if model.ID == "" {
-			return fmt.Errorf("model not found for agent %s", a.agentCfg.Name)
-		}
-
-		promptID := agentPromptMap[a.agentCfg.ID]
-		if promptID == "" {
-			promptID = prompt.PromptDefault
-		}
-
-		opts := []provider.ProviderClientOption{
-			provider.WithModel(a.agentCfg.Model),
-			provider.WithSystemMessage(prompt.GetPrompt(promptID, currentProviderCfg.ID, cfg.Options.ContextPaths...)),
-		}
-
-		newProvider, err := provider.NewProvider(*currentProviderCfg, opts...)
-		if err != nil {
-			return fmt.Errorf("failed to create new provider: %w", err)
-		}
-
-		// Update the provider and provider ID
-		a.provider = newProvider
-		a.providerID = string(currentProviderCfg.ID)
-	}
-
-	// Check if providers have changed for title (small) and summarize (large)
-	smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
-	var smallModelProviderCfg config.ProviderConfig
-	for p := range cfg.Providers.Seq() {
-		if p.ID == smallModelCfg.Provider {
-			smallModelProviderCfg = p
-			break
-		}
-	}
-	if smallModelProviderCfg.ID == "" {
-		return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
-	}
-
-	largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
-	var largeModelProviderCfg config.ProviderConfig
-	for p := range cfg.Providers.Seq() {
-		if p.ID == largeModelCfg.Provider {
-			largeModelProviderCfg = p
-			break
-		}
-	}
-	if largeModelProviderCfg.ID == "" {
-		return fmt.Errorf("provider %s not found in config", largeModelCfg.Provider)
-	}
-
-	var maxTitleTokens int64 = 40
-
-	// if the max output is too low for the gemini provider it won't return anything
-	if smallModelCfg.Provider == "gemini" {
-		maxTitleTokens = 1000
-	}
-	// Recreate title provider
-	titleOpts := []provider.ProviderClientOption{
-		provider.WithModel(config.SelectedModelTypeSmall),
-		provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
-		provider.WithMaxTokens(maxTitleTokens),
-	}
-	newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
-	if err != nil {
-		return fmt.Errorf("failed to create new title provider: %w", err)
-	}
-	a.titleProvider = newTitleProvider
-
-	// Recreate summarize provider if provider changed (now large model)
-	if string(largeModelProviderCfg.ID) != a.summarizeProviderID {
-		largeModel := cfg.GetModelByType(config.SelectedModelTypeLarge)
-		if largeModel == nil {
-			return fmt.Errorf("model %s not found in provider %s", largeModelCfg.Model, largeModelProviderCfg.ID)
-		}
-		summarizeOpts := []provider.ProviderClientOption{
-			provider.WithModel(config.SelectedModelTypeLarge),
-			provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, largeModelProviderCfg.ID)),
-		}
-		newSummarizeProvider, err := provider.NewProvider(largeModelProviderCfg, summarizeOpts...)
-		if err != nil {
-			return fmt.Errorf("failed to create new summarize provider: %w", err)
-		}
-		a.summarizeProvider = newSummarizeProvider
-		a.summarizeProviderID = string(largeModelProviderCfg.ID)
-	}
-
-	return nil
-}
-
-func (a *agent) setupEvents(ctx context.Context) {
-	ctx, cancel := context.WithCancel(ctx)
-
-	go func() {
-		subCh := SubscribeMCPEvents(ctx)
-
-		for {
-			select {
-			case event, ok := <-subCh:
-				if !ok {
-					slog.Debug("MCPEvents subscription channel closed")
-					return
-				}
-				switch event.Payload.Type {
-				case MCPEventToolsListChanged:
-					name := event.Payload.Name
-					c, ok := mcpClients.Get(name)
-					if !ok {
-						slog.Warn("MCP client not found for tools update", "name", name)
-						continue
-					}
-					cfg := config.Get()
-					tools, err := getTools(ctx, name, a.permissions, c, cfg.WorkingDir())
-					if err != nil {
-						slog.Error("error listing tools", "error", err)
-						updateMCPState(name, MCPStateError, err, nil, 0)
-						_ = c.Close()
-						continue
-					}
-					updateMcpTools(name, tools)
-					a.mcpTools.Reset(maps.Collect(mcpTools.Seq2()))
-					updateMCPState(name, MCPStateConnected, nil, c, a.mcpTools.Len())
-				default:
-					continue
-				}
-			case <-ctx.Done():
-				slog.Debug("MCPEvents subscription cancelled")
-				return
-			}
-		}
-	}()
-
-	a.cleanupFuncs = append(a.cleanupFuncs, cancel)
-}
diff --git a/internal/llm/agent/event.go b/internal/llm/agent/event.go
deleted file mode 100644
index 8642d999..00000000
--- a/internal/llm/agent/event.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package agent
-
-import (
-	"time"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/event"
-	"github.com/charmbracelet/crush/internal/llm/provider"
-)
-
-func (a *agent) eventPromptSent(sessionID string) {
-	event.PromptSent(
-		a.eventCommon(sessionID)...,
-	)
-}
-
-func (a *agent) eventPromptResponded(sessionID string, duration time.Duration) {
-	event.PromptResponded(
-		append(
-			a.eventCommon(sessionID),
-			"prompt duration pretty", duration.String(),
-			"prompt duration in seconds", int64(duration.Seconds()),
-		)...,
-	)
-}
-
-func (a *agent) eventTokensUsed(sessionID string, usage provider.TokenUsage, cost float64) {
-	event.TokensUsed(
-		append(
-			a.eventCommon(sessionID),
-			"input tokens", usage.InputTokens,
-			"output tokens", usage.OutputTokens,
-			"cache read tokens", usage.CacheReadTokens,
-			"cache creation tokens", usage.CacheCreationTokens,
-			"total tokens", usage.InputTokens+usage.OutputTokens+usage.CacheReadTokens+usage.CacheCreationTokens,
-			"cost", cost,
-		)...,
-	)
-}
-
-func (a *agent) eventCommon(sessionID string) []any {
-	cfg := config.Get()
-	currentModel := cfg.Models[cfg.Agents["coder"].Model]
-
-	return []any{
-		"session id", sessionID,
-		"provider", currentModel.Provider,
-		"model", currentModel.Model,
-		"reasoning effort", currentModel.ReasoningEffort,
-		"thinking mode", currentModel.Think,
-		"yolo mode", a.permissions.SkipRequests(),
-	}
-}
diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go
deleted file mode 100644
index 6838c54a..00000000
--- a/internal/llm/agent/mcp-tools.go
+++ /dev/null
@@ -1,504 +0,0 @@
-package agent
-
-import (
-	"cmp"
-	"context"
-	"encoding/json"
-	"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/llm/tools"
-	"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"
-	}
-}
-
-// 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, tools.BaseTool]()
-	mcpClient2Tools = csync.NewMap[string, []tools.BaseTool]()
-	mcpClients      = csync.NewMap[string, *mcp.ClientSession]()
-	mcpStates       = csync.NewMap[string, MCPClientInfo]()
-	mcpBroker       = pubsub.NewBroker[MCPEvent]()
-)
-
-type McpTool struct {
-	mcpName     string
-	tool        *mcp.Tool
-	permissions permission.Service
-	workingDir  string
-}
-
-func (b *McpTool) Name() string {
-	return fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name)
-}
-
-func (b *McpTool) Info() tools.ToolInfo {
-	parameters := make(map[string]any)
-	required := make([]string, 0)
-
-	if input, ok := b.tool.InputSchema.(map[string]any); ok {
-		if props, ok := input["properties"].(map[string]any); ok {
-			parameters = props
-		}
-		if req, ok := input["required"].([]any); ok {
-			// Convert []any -> []string when elements are strings
-			for _, v := range req {
-				if s, ok := v.(string); ok {
-					required = append(required, s)
-				}
-			}
-		} else if reqStr, ok := input["required"].([]string); ok {
-			// Handle case where it's already []string
-			required = reqStr
-		}
-	}
-
-	return tools.ToolInfo{
-		Name:        fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
-		Description: b.tool.Description,
-		Parameters:  parameters,
-		Required:    required,
-	}
-}
-
-func runTool(ctx context.Context, name, toolName string, input string) (tools.ToolResponse, error) {
-	var args map[string]any
-	if err := json.Unmarshal([]byte(input), &args); err != nil {
-		return tools.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	c, err := getOrRenewClient(ctx, name)
-	if err != nil {
-		return tools.NewTextErrorResponse(err.Error()), nil
-	}
-	result, err := c.CallTool(ctx, &mcp.CallToolParams{
-		Name:      toolName,
-		Arguments: args,
-	})
-	if err != nil {
-		return tools.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 tools.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 (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
-	sessionID, messageID := tools.GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return tools.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
-	}
-	permissionDescription := fmt.Sprintf("execute %s with the following parameters:", b.Info().Name)
-	p := b.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			ToolCallID:  params.ID,
-			Path:        b.workingDir,
-			ToolName:    b.Info().Name,
-			Action:      "execute",
-			Description: permissionDescription,
-			Params:      params.Input,
-		},
-	)
-	if !p {
-		return tools.ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	return runTool(ctx, b.mcpName, b.tool.Name, params.Input)
-}
-
-func getTools(ctx context.Context, name string, permissions permission.Service, c *mcp.ClientSession, workingDir string) ([]tools.BaseTool, error) {
-	result, err := c.ListTools(ctx, &mcp.ListToolsParams{})
-	if err != nil {
-		return nil, err
-	}
-	mcpTools := make([]tools.BaseTool, 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 doGetMCPTools(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 {
-			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()
-}
-
-// updateMcpTools updates the global mcpTools and mcpClient2Tools maps
-func updateMcpTools(mcpName string, tools []tools.BaseTool) {
-	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 fmt.Errorf("%w: %s", err, string(out))
-}
diff --git a/internal/llm/prompt/anthropic.md b/internal/llm/prompt/anthropic.md
deleted file mode 100644
index c025e1cd..00000000
--- a/internal/llm/prompt/anthropic.md
+++ /dev/null
@@ -1,108 +0,0 @@
-You are Crush, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
-
-IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
-
-# Memory
-
-If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
-
-1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
-2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
-3. Maintaining useful information about the codebase structure and organization
-
-When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
-
-# Tone and style
-
-You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
-Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
-Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
-If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
-IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
-IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
-IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
-<example>
-user: 2 + 2
-assistant: 4
-</example>
-
-<example>
-user: what is 2+2?
-assistant: 4
-</example>
-
-<example>
-user: is 11 a prime number?
-assistant: true
-</example>
-
-<example>
-user: what command should I run to list files in the current directory?
-assistant: ls
-</example>
-
-<example>
-user: what command should I run to watch files in the current directory?
-assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
-npm run dev
-</example>
-
-<example>
-user: How many golf balls fit inside a jetta?
-assistant: 150000
-</example>
-
-<example>
-user: what files are in the directory src/?
-assistant: [runs ls and sees foo.c, bar.c, baz.c]
-user: which file contains the implementation of foo?
-assistant: src/foo.c
-</example>
-
-<example>
-user: write tests for new feature
-assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
-</example>
-
-# Proactiveness
-
-You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
-
-1. Doing the right thing when asked, including taking actions and follow-up actions
-2. Not surprising the user with actions you take without asking
-   For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
-3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
-
-# Following conventions
-
-When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
-
-- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
-- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
-- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
-- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
-
-# Code style
-
-- IMPORTANT: DO NOT ADD **_ANY_** COMMENTS unless asked
-
-# Doing tasks
-
-The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
-
-1. Use the available search tools to understand the codebase and the user's query.
-2. Implement the solution using all tools available to you
-3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
-4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CRUSH.md so that you will know to run it next time.
-
-NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
-
-# Tool usage policy
-
-- When doing file search, prefer to use the Agent tool in order to reduce context usage.
-- IMPORTANT: All tools are executed in parallel when multiple tool calls are sent in a single message. Only send multiple tool calls when they are safe to run in parallel (no dependencies between them).
-- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
-
-VERY IMPORTANT NEVER use emojis in your responses.
-
-You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
diff --git a/internal/llm/prompt/coder.go b/internal/llm/prompt/coder.go
deleted file mode 100644
index 57ed088b..00000000
--- a/internal/llm/prompt/coder.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package prompt
-
-import (
-	_ "embed"
-	"fmt"
-	"os"
-	"path/filepath"
-	"runtime"
-	"strconv"
-	"time"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-)
-
-func CoderPrompt(p string, contextFiles ...string) string {
-	var basePrompt string
-
-	basePrompt = string(anthropicCoderPrompt)
-	switch p {
-	case string(catwalk.InferenceProviderOpenAI):
-		// seems to behave better
-		basePrompt = string(coderV2Prompt)
-	case string(catwalk.InferenceProviderGemini):
-		basePrompt = string(geminiCoderPrompt)
-	}
-	if ok, _ := strconv.ParseBool(os.Getenv("CRUSH_CODER_V2")); ok {
-		basePrompt = string(coderV2Prompt)
-	}
-	envInfo := getEnvironmentInfo()
-
-	basePrompt = fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
-
-	contextContent := getContextFromPaths(config.Get().WorkingDir(), contextFiles)
-	if contextContent != "" {
-		return fmt.Sprintf("%s\n\n# Project-Specific Context\n Make sure to follow the instructions in the context below\n%s", basePrompt, contextContent)
-	}
-	return basePrompt
-}
-
-//go:embed anthropic.md
-var anthropicCoderPrompt []byte
-
-//go:embed gemini.md
-var geminiCoderPrompt []byte
-
-//go:embed v2.md
-var coderV2Prompt []byte
-
-func getEnvironmentInfo() string {
-	cwd := config.Get().WorkingDir()
-	isGit := isGitRepo(cwd)
-	platform := runtime.GOOS
-	date := time.Now().Format("1/2/2006")
-	output, _, _ := tools.ListDirectoryTree(cwd, tools.LSParams{})
-	return fmt.Sprintf(`Here is useful information about the environment you are running in:
-<env>
-Working directory: %s
-Is directory a git repo: %s
-Platform: %s
-Today's date: %s
-</env>
-<project>
-%s
-</project>
-		`, cwd, boolToYesNo(isGit), platform, date, output)
-}
-
-func isGitRepo(dir string) bool {
-	_, err := os.Stat(filepath.Join(dir, ".git"))
-	return err == nil
-}
-
-func lspInformation() string {
-	cfg := config.Get()
-	hasLSP := false
-	for _, v := range cfg.LSP {
-		if !v.Disabled {
-			hasLSP = true
-			break
-		}
-	}
-	if !hasLSP {
-		return ""
-	}
-	return `# LSP Information
-Tools that support it will also include useful diagnostics such as linting and typechecking.
-- These diagnostics will be automatically enabled when you run the tool, and will be displayed in the output at the bottom within the <file_diagnostics></file_diagnostics> and <project_diagnostics></project_diagnostics> tags.
-- Take necessary actions to fix the issues.
-- You should ignore diagnostics of files that you did not change or are not related or caused by your changes unless the user explicitly asks you to fix them.
-`
-}
-
-func boolToYesNo(b bool) string {
-	if b {
-		return "Yes"
-	}
-	return "No"
-}
diff --git a/internal/llm/prompt/gemini.md b/internal/llm/prompt/gemini.md
deleted file mode 100644
index 4b91258d..00000000
--- a/internal/llm/prompt/gemini.md
+++ /dev/null
@@ -1,165 +0,0 @@
-You are an interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools.
-
-IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
-
-# Memory
-
-If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
-
-1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
-2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
-3. Maintaining useful information about the codebase structure and organization
-
-When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
-
-# Core Mandates
-
-- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
-- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
-- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
-- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically.
-- **Comments:** Add code comments sparingly. Focus on _why_ something is done, especially for complex logic, rather than _what_ is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. _NEVER_ talk to the user or describe your changes through comments.
-- **Proactiveness:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions.
-- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked _how_ to do something, explain first, don't just do it.
-- **Explaining Changes:** After completing a code modification or file operation _do not_ provide summaries unless asked.
-- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
-
-# Code style
-
-- IMPORTANT: DO NOT ADD **_ANY_** COMMENTS unless asked
-
-# Primary Workflows
-
-## Software Engineering Tasks
-
-When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
-
-1. **Understand:** Think about the user's request and the relevant codebase context. Use `grep` and `glob` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use `view` to understand context and validate any assumptions you may have.
-2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should try to use a self-verification loop by writing unit tests if relevant to the task. Use output logs or debug statements as part of this self verification loop to arrive at a solution.
-3. **Implement:** Use the available tools (e.g., `edit`, `write` `bash` ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
-4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
-5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
-
-NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
-
-# Operational Guidelines
-
-## Tone and Style (CLI Interaction)
-
-- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
-- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query.
-- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous.
-- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer.
-- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace.
-- **Tools vs. Text:** Use tools for actions, text output _only_ for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself.
-- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate.
-
-## Security and Safety Rules
-
-- **Explain Critical Commands:** Before executing commands with `bash` that modify the file system, codebase, or system state, you _must_ provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety.
-- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
-
-## Tool Usage
-
-- **File Paths:** Always use absolute paths when referring to files with tools like `view` or `write`. Relative paths are not supported. You must provide an absolute path.
-- **Parallelism:** IMPORTANT: All tools are executed in parallel when multiple tool calls are sent in a single message. Only send multiple tool calls when they are safe to run in parallel (no dependencies between them).
-- **Command Execution:** Use the `bash` tool for running shell commands, remembering the safety rule to explain modifying commands first.
-- **Background Processes:** Use background processes (via `&`) for commands that are unlikely to stop on their own, e.g. `node server.js &`. If unsure, ask the user.
-- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. `git rebase -i`). Use non-interactive versions of commands (e.g. `npm init -y` instead of `npm init`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user.
-
-# Examples (Illustrating Tone and Workflow)
-
-<example>
-user: 1 + 2
-model: 3
-</example>
-
-<example>
-user: is 13 a prime number?
-model: true
-</example>
-
-<example>
-user: list files here.
-model: [tool_call: ls for path '.']
-</example>
-
-<example>
-user: start the server implemented in server.js
-model: [tool_call: bash for 'node server.js &' because it must run in the background]
-</example>
-
-<example>
-user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
-model: Okay, I can refactor 'src/auth.py'.
-First, I'll analyze the code and check for a test safety net before planning any changes.
-[tool_call: glob for pattern 'tests/test_auth.py']
-[tool_call: view for absolute_path '/path/to/tests/test_auth.py']
-(After analysis)
-Great, 'tests/test_auth.py' exists and covers the core authentication logic. With this safety net in place, I can safely plan the refactoring.
-I'll also confirm 'requests' is a dependency.
-[tool_call: view for absolute_path '/path/to/requirements.txt']
-(After analysis)
-Looks good, 'requests' is available.
-
-Here's the plan:
-
-1. Replace the 'urllib' calls with 'requests'.
-2. Add proper 'try...except' error handling for the new network calls.
-3. Remove the old 'urllib' import.
-4. Run the project's linter and tests to verify the changes.
-
-Should I proceed?
-user: Yes
-model:
-[tool_call: edit to apply the refactoring to 'src/auth.py']
-Refactoring complete. Running verification...
-[tool_call: bash for 'ruff check src/auth.py && pytest']
-(After verification passes)
-All checks passed. This is a stable checkpoint.
-</example>
-
-<example>
-user: Delete the temp directory.
-model: I can run `rm -rf ./temp`. This will permanently delete the directory and all its contents.
-</example>
-
-<example>
-user: Write tests for someFile.ts
-model:
-Okay, I can write those tests. First, I'll read `someFile.ts` to understand its functionality.
-[tool_call: view for absolute_path '/path/to/someFile.ts' or use glob to find `someFile.ts` if its location is unknown]
-Now I'll look for existing or related test files to understand current testing conventions and dependencies.
-[tool_call: glob for pattern '**/*.test.ts' and '**/*.spec.ts']
-(After reviewing existing tests and the file content)
-[tool_call: write to create /path/to/someFile.test.ts with the test code]
-I've written the tests. Now I'll run the project's test command to verify them.
-[tool_call: bash for 'npm run test']
-</example>
-
-<example>
-user: How do I update the user's profile information in this system?
-model:
-I'm not immediately sure how user profile information is updated. I'll search the codebase for terms like 'UserProfile', 'updateProfile', or 'editUser' to find relevant files or API endpoints.
-[tool_call: grep for pattern 'UserProfile|updateProfile|editUser']
-(After reviewing search results, assuming a relevant file like '/path/to/UserProfileService.java' was found)
-Okay, `/path/to/UserProfileService.java` seems like the most relevant file. I'll read its content to understand how updates are handled.
-[tool_call: view for absolute_path '/path/to/UserProfileService.java']
-(After reading the file)
-It appears the `updateUserProfile` method in `UserProfileService.java` is responsible for this. It expects a user ID and a `UserProfileDTO` object...
-</example>
-
-<example>
-user: Where are all the 'app.config' files in this project? I need to check their settings.
-model:
-[tool_call: glob for pattern '**/app.config']
-(Assuming glob returns a list of paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
-I found the following 'app.config' files:
-- /path/to/moduleA/app.config
-- /path/to/moduleB/app.config
-To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
-</example>
-
-# Final Reminder
-
-Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use `view` to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved.
diff --git a/internal/llm/prompt/init.md b/internal/llm/prompt/init.md
deleted file mode 100644
index edada7f7..00000000
--- a/internal/llm/prompt/init.md
+++ /dev/null
@@ -1,9 +0,0 @@
-`Please analyze this codebase and create a **CRUSH.md** file containing:
-
-- Build/lint/test commands - especially for running a single test
-- Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
-
-The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20-30 lines long.
-If there's already a **CRUSH.md**, improve it.
-
-If there are Cursor rules (in `.cursor/rules/` or `.cursorrules`) or Copilot rules (in `.github/copilot-instructions.md`), make sure to include them.
diff --git a/internal/llm/prompt/initialize.go b/internal/llm/prompt/initialize.go
deleted file mode 100644
index 8dfe0d14..00000000
--- a/internal/llm/prompt/initialize.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package prompt
-
-import _ "embed"
-
-//go:embed init.md
-var initPrompt []byte
-
-func Initialize() string {
-	return string(initPrompt)
-}
diff --git a/internal/llm/prompt/prompt.go b/internal/llm/prompt/prompt.go
deleted file mode 100644
index 919686a7..00000000
--- a/internal/llm/prompt/prompt.go
+++ /dev/null
@@ -1,143 +0,0 @@
-package prompt
-
-import (
-	"os"
-	"path/filepath"
-	"strings"
-	"sync"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/env"
-	"github.com/charmbracelet/crush/internal/home"
-)
-
-type PromptID string
-
-const (
-	PromptCoder      PromptID = "coder"
-	PromptTitle      PromptID = "title"
-	PromptTask       PromptID = "task"
-	PromptSummarizer PromptID = "summarizer"
-	PromptDefault    PromptID = "default"
-)
-
-func GetPrompt(promptID PromptID, provider string, contextPaths ...string) string {
-	basePrompt := ""
-	switch promptID {
-	case PromptCoder:
-		basePrompt = CoderPrompt(provider, contextPaths...)
-	case PromptTitle:
-		basePrompt = TitlePrompt()
-	case PromptTask:
-		basePrompt = TaskPrompt()
-	case PromptSummarizer:
-		basePrompt = SummarizerPrompt()
-	default:
-		basePrompt = "You are a helpful assistant"
-	}
-	return basePrompt
-}
-
-func getContextFromPaths(workingDir string, contextPaths []string) string {
-	return processContextPaths(workingDir, contextPaths)
-}
-
-// expandPath expands ~ and environment variables in file paths
-func expandPath(path string) string {
-	path = home.Long(path)
-
-	// Handle environment variable expansion using the same pattern as config
-	if strings.HasPrefix(path, "$") {
-		resolver := config.NewEnvironmentVariableResolver(env.New())
-		if expanded, err := resolver.ResolveValue(path); err == nil {
-			path = expanded
-		}
-	}
-
-	return path
-}
-
-func processContextPaths(workDir string, paths []string) string {
-	var (
-		wg       sync.WaitGroup
-		resultCh = make(chan string)
-	)
-
-	// Track processed files to avoid duplicates
-	processedFiles := csync.NewMap[string, bool]()
-
-	for _, path := range paths {
-		wg.Add(1)
-		go func(p string) {
-			defer wg.Done()
-
-			// Expand ~ and environment variables before processing
-			p = expandPath(p)
-
-			// Use absolute path if provided, otherwise join with workDir
-			fullPath := p
-			if !filepath.IsAbs(p) {
-				fullPath = filepath.Join(workDir, p)
-			}
-
-			// Check if the path is a directory using os.Stat
-			info, err := os.Stat(fullPath)
-			if err != nil {
-				return // Skip if path doesn't exist or can't be accessed
-			}
-
-			if info.IsDir() {
-				filepath.WalkDir(fullPath, func(path string, d os.DirEntry, err error) error {
-					if err != nil {
-						return err
-					}
-					if !d.IsDir() {
-						// Check if we've already processed this file (case-insensitive)
-						lowerPath := strings.ToLower(path)
-
-						if alreadyProcessed, _ := processedFiles.Get(lowerPath); !alreadyProcessed {
-							processedFiles.Set(lowerPath, true)
-							if result := processFile(path); result != "" {
-								resultCh <- result
-							}
-						}
-					}
-					return nil
-				})
-			} else {
-				// It's a file, process it directly
-				// Check if we've already processed this file (case-insensitive)
-				lowerPath := strings.ToLower(fullPath)
-
-				if alreadyProcessed, _ := processedFiles.Get(lowerPath); !alreadyProcessed {
-					processedFiles.Set(lowerPath, true)
-					result := processFile(fullPath)
-					if result != "" {
-						resultCh <- result
-					}
-				}
-			}
-		}(path)
-	}
-
-	go func() {
-		wg.Wait()
-		close(resultCh)
-	}()
-
-	results := make([]string, 0)
-	for result := range resultCh {
-		results = append(results, result)
-	}
-
-	return strings.Join(results, "\n")
-}
-
-func processFile(filePath string) string {
-	content, err := os.ReadFile(filePath)
-	if err != nil {
-		return ""
-	}
-	return "# From:" + filePath + "\n" + string(content)
-}
diff --git a/internal/llm/prompt/summarize.md b/internal/llm/prompt/summarize.md
deleted file mode 100644
index 5a40e6b3..00000000
--- a/internal/llm/prompt/summarize.md
+++ /dev/null
@@ -1,11 +0,0 @@
-You are a helpful AI assistant tasked with summarizing conversations.
-
-When asked to summarize, provide a detailed but concise summary of the conversation.
-Focus on information that would be helpful for continuing the conversation, including:
-
-- What was done
-- What is currently being worked on
-- Which files are being modified
-- What needs to be done next
-
-Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
diff --git a/internal/llm/prompt/summarizer.go b/internal/llm/prompt/summarizer.go
deleted file mode 100644
index e715128b..00000000
--- a/internal/llm/prompt/summarizer.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package prompt
-
-import _ "embed"
-
-//go:embed summarize.md
-var summarizePrompt []byte
-
-func SummarizerPrompt() string {
-	return string(summarizePrompt)
-}
diff --git a/internal/llm/prompt/task.go b/internal/llm/prompt/task.go
deleted file mode 100644
index e4f021d4..00000000
--- a/internal/llm/prompt/task.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package prompt
-
-import (
-	"fmt"
-)
-
-func TaskPrompt() string {
-	agentPrompt := `You are an agent for Crush. Given the user's prompt, you should use the tools available to you to answer the user's question.
-Notes:
-1. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
-2. When relevant, share file names and code snippets relevant to the query
-3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.`
-
-	return fmt.Sprintf("%s\n%s\n", agentPrompt, getEnvironmentInfo())
-}
diff --git a/internal/llm/prompt/title.go b/internal/llm/prompt/title.go
deleted file mode 100644
index 18a6e835..00000000
--- a/internal/llm/prompt/title.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package prompt
-
-import _ "embed"
-
-//go:embed title.md
-var titlePrompt []byte
-
-func TitlePrompt() string {
-	return string(titlePrompt)
-}
diff --git a/internal/llm/prompt/v2.md b/internal/llm/prompt/v2.md
deleted file mode 100644
index 1477ad91..00000000
--- a/internal/llm/prompt/v2.md
+++ /dev/null
@@ -1,267 +0,0 @@
-You are Crush, an autonomous software engineering agent that helps users with coding tasks. Use the instructions below and the tools available to you to assist the user.
-
-# Core Principles
-
-You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user.
-
-Your thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough.
-
-You MUST iterate and keep going until the problem is solved.
-
-You have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me.
-
-Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
-
-**IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames, directory structure, and existing codebase patterns.**
-
-When the user provides URLs or when you need to research external information, use the fetch tool to gather that information. If you find relevant links in the fetched content, follow them to gather comprehensive information.
-
-When working with third-party packages, libraries, or frameworks that you're unfamiliar with or need to verify usage patterns for, you can use the Sourcegraph tool to search for code examples across public repositories. This can help you understand best practices and common implementation patterns.
-
-Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.
-
-If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is.
-
-Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking approach if needed. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.
-
-You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
-
-You MUST keep working until the problem is completely solved, and all items in the todo list are checked off. Do not end your turn until you have completed all steps in the todo list and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it.
-
-You are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input.
-
-# Proactiveness and Balance
-
-You should strive to strike a balance between:
-
-1. Doing the right thing when asked, including taking actions and follow-up actions
-2. Not surprising the user with actions you take without asking
-3. Being thorough and autonomous while staying focused on the user's actual request
-
-For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions. However, when they ask you to solve a problem or implement something, be proactive in completing the entire task.
-
-# Workflow
-
-1. **Understand the Context**: Think about what the code you're editing is supposed to do based on filenames, directory structure, and existing patterns.
-2. **Fetch URLs**: Fetch any URLs provided by the user using the `fetch` tool.
-3. **Deep Problem Understanding**: Carefully read the issue and think critically about what is required.
-4. **Codebase Investigation**: Explore relevant files, search for key functions, and gather context.
-5. **Research**: If needed, research the problem using available tools.
-6. **Plan Development**: Develop a clear, step-by-step plan with a todo list.
-7. **Incremental Implementation**: Make small, testable code changes.
-8. **Debug and Test**: Debug as needed and test frequently.
-9. **Iterate**: Continue until the root cause is fixed and all tests pass.
-10. **Comprehensive Validation**: Reflect and validate thoroughly after tests pass.
-
-Refer to the detailed sections below for more information on each step.
-
-## 1. Understanding Context and Fetching URLs
-
-- **Context First**: Before diving into code, understand what the existing code is supposed to do based on file names, directory structure, imports, and existing patterns.
-- **URL Fetching**: If the user provides a URL, use the `fetch` tool to retrieve the content.
-- **Recursive Information Gathering**: If you find additional relevant URLs or links, fetch those as well until you have all necessary information.
-
-## 2. Deep Problem Understanding
-
-Carefully read the issue and think hard about a plan to solve it before coding. Consider:
-
-- What is the expected behavior?
-- What are the edge cases?
-- What are the potential pitfalls?
-- How does this fit into the larger context of the codebase?
-- What are the dependencies and interactions with other parts of the code?
-
-## 3. Codebase Investigation
-
-- Explore relevant files and directories using `ls`, `view`, `glob`, and `grep` tools.
-- Search for key functions, classes, or variables related to the issue.
-- Read and understand relevant code snippets.
-- Identify the root cause of the problem.
-- Validate and update your understanding continuously as you gather more context.
-
-## 4. Research When Needed
-
-- Use the `sourcegraph` tool when you need to find code examples or verify usage patterns for libraries/frameworks.
-- Use the `fetch` tool to retrieve documentation or other web resources.
-- Look for patterns, best practices, and implementation examples.
-- Focus your research on what's necessary to solve the specific problem at hand.
-
-## 5. Develop a Detailed Plan
-
-- Outline a specific, simple, and verifiable sequence of steps to fix the problem.
-- Create a todo list in markdown format to track your progress.
-- Each time you complete a step, check it off using `[x]` syntax.
-- Each time you check off a step, display the updated todo list to the user.
-- Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn.
-
-## 6. Making Code Changes
-
-- Before editing, always read the relevant file contents or section to ensure complete context using the `view` tool.
-- Always read at least 2000 lines of code at a time to ensure you have enough context.
-- If a patch is not applied correctly, attempt to reapply it.
-- Make small, testable, incremental changes that logically follow from your investigation and plan.
-- Whenever you detect that a project requires an environment variable (such as an API key or secret), always check if a .env file exists in the project root. If it does not exist, automatically create a .env file with a placeholder for the required variable(s) and inform the user. Do this proactively, without waiting for the user to request it.
-- Prefer using the `multiedit` tool when making multiple edits to the same file.
-
-## 7. Debugging and Testing
-
-- Use the `bash` tool to run commands and check for errors.
-- Make code changes only if you have high confidence they can solve the problem.
-- When debugging, try to determine the root cause rather than addressing symptoms.
-- Debug for as long as needed to identify the root cause and identify a fix.
-- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening.
-- To test hypotheses, you can also add test statements or functions.
-- Revisit your assumptions if unexpected behavior occurs.
-- **Test rigorously and frequently** - this is critical for success.
-
-# Memory
-
-If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
-
-1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
-2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
-3. Maintaining useful information about the codebase structure and organization
-
-When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
-
-# How to Create a Todo List
-
-Use the following format to create a todo list:
-
-```markdown
-- [ ] Step 1: Description of the first step
-- [ ] Step 2: Description of the second step
-- [ ] Step 3: Description of the third step
-```
-
-Do not ever use HTML tags or any other formatting for the todo list, as it will not be rendered correctly. Always use the markdown format shown above. Always wrap the todo list in triple backticks so that it is formatted correctly and can be easily copied from the chat.
-
-Always show the completed todo list to the user as the last item in your message, so that they can see that you have addressed all of the steps.
-
-# Communication Guidelines
-
-Always communicate clearly and concisely in a casual, friendly yet professional tone.
-
-<examples>
-"Let me fetch the URL you provided to gather more information."
-"Ok, I've got all of the information I need on the API and I know how to use it."
-"Now, I will search the codebase for the function that handles the API requests."
-"I need to update several files here - stand by"
-"OK! Now let's run the tests to make sure everything is working correctly."
-"Whelp - I see we have some problems. Let's fix those up."
-</examples>
-
-- Respond with clear, direct answers. Use bullet points and code blocks for structure.
-- Avoid unnecessary explanations, repetition, and filler.
-- Always write code directly to the correct files.
-- Do not display code to the user unless they specifically ask for it.
-- Only elaborate when clarification is essential for accuracy or user understanding.
-
-# Tone and Style
-
-You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
-
-Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
-
-Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
-
-If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
-
-IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request.
-
-IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
-
-VERY IMPORTANT: NEVER use emojis in your responses.
-
-# Following Conventions
-
-When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
-
-- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
-- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
-- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
-- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
-
-# Code Style
-
-- IMPORTANT: DO NOT ADD **_ANY_** COMMENTS unless asked
-
-# Task Execution
-
-The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
-
-1. Use the available search tools to understand the codebase and the user's query.
-2. Implement the solution using all tools available to you
-3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
-4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CRUSH.md so that you will know to run it next time.
-
-NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
-
-# Tool Usage Policy
-
-- When doing file search, prefer to use the Agent tool in order to reduce context usage.
-- **IMPORTANT**: If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel for efficiency.
-- **IMPORTANT**: The user does not see the full output of the tool responses, so if you need the output of the tool for your response, make sure to summarize it for the user.
-- All tools are executed in parallel when multiple tool calls are sent in a single message. Only send multiple tool calls when they are safe to run in parallel (no dependencies between them).
-
-# Reading Files and Folders
-
-**Always check if you have already read a file, folder, or workspace structure before reading it again.**
-
-- If you have already read the content and it has not changed, do NOT re-read it.
-- Only re-read files or folders if:
-  - You suspect the content has changed since your last read.
-  - You have made edits to the file or folder.
-  - You encounter an error that suggests the context may be stale or incomplete.
-- Use your internal memory and previous context to avoid redundant reads.
-- This will save time, reduce unnecessary operations, and make your workflow more efficient.
-
-# Directory Context and Navigation
-
-**Always maintain awareness of your current working directory by tracking it mentally from the command history.**
-
-- **Remember directory changes**: When you use `cd` to change directories, mentally note and remember the new location for all subsequent operations.
-- **Track your location from context**: Use the command history and previous `cd` commands to know where you currently are without constantly checking.
-- **Check location only when commands fail**: If a command fails unexpectedly with file/path errors, then use `pwd` to verify your current directory as the failure might be due to being in the wrong location.
-- **Use relative paths confidently**: Once you know your location, use relative paths appropriately based on your mental model of the current directory.
-- **Maintain directory awareness across operations**: Keep track of where you are throughout a multi-step task, especially when working with files in different directories.
-
-**When to verify with `pwd`:**
-
-- After a command fails with "file not found" or similar path-related or `exit status 1` errors
-- When resuming work or continuing from a previous step if uncertain
-- When you realize you may have lost track of your current location
-
-**Mental tracking example:**
-
-```bash
-# You start in /project/root
-cd src/components  # Now mentally note: I'm in /project/root/src/components
-# Work with files here using relative paths
-ls ./Button.tsx  # This should work because I know I'm in components/
-# If this fails, THEN run pwd to double-check location
-```
-
-# Git and Version Control
-
-If the user tells you to stage and commit, you may do so.
-
-You are NEVER allowed to stage and commit files automatically. Only do this when explicitly requested.
-
-# Error Handling and Recovery
-
-- When you encounter errors, don't give up - analyze the error carefully and try alternative approaches.
-- If a tool fails, try a different tool or approach to accomplish the same goal.
-- When debugging, be systematic: isolate the problem, test hypotheses, and iterate until resolved.
-- Always validate your solutions work correctly before considering the task complete.
-
-# Final Validation
-
-Before completing any task:
-
-1. Ensure all todo items are checked off
-2. Run all relevant tests
-3. Run linting and type checking if available
-4. Verify the original problem is solved
-5. Test edge cases and boundary conditions
-6. Confirm no regressions were introduced
diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go
deleted file mode 100644
index 1f1965fd..00000000
--- a/internal/llm/provider/anthropic.go
+++ /dev/null
@@ -1,598 +0,0 @@
-package provider
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"net/http"
-	"regexp"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/anthropic-sdk-go"
-	"github.com/charmbracelet/anthropic-sdk-go/bedrock"
-	"github.com/charmbracelet/anthropic-sdk-go/option"
-	"github.com/charmbracelet/anthropic-sdk-go/vertex"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/log"
-	"github.com/charmbracelet/crush/internal/message"
-)
-
-// Pre-compiled regex for parsing context limit errors.
-var contextLimitRegex = regexp.MustCompile(`input length and ` + "`max_tokens`" + ` exceed context limit: (\d+) \+ (\d+) > (\d+)`)
-
-type anthropicClient struct {
-	providerOptions   providerClientOptions
-	tp                AnthropicClientType
-	client            anthropic.Client
-	adjustedMaxTokens int // Used when context limit is hit
-}
-
-type AnthropicClient ProviderClient
-
-type AnthropicClientType string
-
-const (
-	AnthropicClientTypeNormal  AnthropicClientType = "normal"
-	AnthropicClientTypeBedrock AnthropicClientType = "bedrock"
-	AnthropicClientTypeVertex  AnthropicClientType = "vertex"
-)
-
-func newAnthropicClient(opts providerClientOptions, tp AnthropicClientType) AnthropicClient {
-	return &anthropicClient{
-		providerOptions: opts,
-		tp:              tp,
-		client:          createAnthropicClient(opts, tp),
-	}
-}
-
-func createAnthropicClient(opts providerClientOptions, tp AnthropicClientType) anthropic.Client {
-	anthropicClientOptions := []option.RequestOption{}
-
-	// Check if Authorization header is provided in extra headers
-	hasBearerAuth := false
-	if opts.extraHeaders != nil {
-		for key := range opts.extraHeaders {
-			if strings.ToLower(key) == "authorization" {
-				hasBearerAuth = true
-				break
-			}
-		}
-	}
-
-	isBearerToken := strings.HasPrefix(opts.apiKey, "Bearer ")
-
-	if opts.apiKey != "" && !hasBearerAuth {
-		if isBearerToken {
-			slog.Debug("API key starts with 'Bearer ', using as Authorization header")
-			anthropicClientOptions = append(anthropicClientOptions, option.WithHeader("Authorization", opts.apiKey))
-		} else {
-			// Use standard X-Api-Key header
-			anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
-		}
-	} else if hasBearerAuth {
-		slog.Debug("Skipping X-Api-Key header because Authorization header is provided")
-	}
-
-	if opts.baseURL != "" {
-		resolvedBaseURL, err := config.Get().Resolve(opts.baseURL)
-		if err == nil && resolvedBaseURL != "" {
-			anthropicClientOptions = append(anthropicClientOptions, option.WithBaseURL(resolvedBaseURL))
-		}
-	}
-
-	if config.Get().Options.Debug {
-		httpClient := log.NewHTTPClient()
-		anthropicClientOptions = append(anthropicClientOptions, option.WithHTTPClient(httpClient))
-	}
-
-	switch tp {
-	case AnthropicClientTypeBedrock:
-		anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
-	case AnthropicClientTypeVertex:
-		project := opts.extraParams["project"]
-		location := opts.extraParams["location"]
-		anthropicClientOptions = append(anthropicClientOptions, vertex.WithGoogleAuth(context.Background(), location, project))
-	}
-	for key, header := range opts.extraHeaders {
-		anthropicClientOptions = append(anthropicClientOptions, option.WithHeaderAdd(key, header))
-	}
-	for key, value := range opts.extraBody {
-		anthropicClientOptions = append(anthropicClientOptions, option.WithJSONSet(key, value))
-	}
-	return anthropic.NewClient(anthropicClientOptions...)
-}
-
-func (a *anthropicClient) convertMessages(messages []message.Message) (anthropicMessages []anthropic.MessageParam) {
-	for i, msg := range messages {
-		cache := false
-		if i > len(messages)-3 {
-			cache = true
-		}
-		switch msg.Role {
-		case message.User:
-			content := anthropic.NewTextBlock(msg.Content().String())
-			if cache && !a.providerOptions.disableCache {
-				content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
-					Type: "ephemeral",
-				}
-			}
-			var contentBlocks []anthropic.ContentBlockParamUnion
-			contentBlocks = append(contentBlocks, content)
-			for _, binaryContent := range msg.BinaryContent() {
-				base64Image := binaryContent.String(catwalk.InferenceProviderAnthropic)
-				imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
-				contentBlocks = append(contentBlocks, imageBlock)
-			}
-			anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...))
-
-		case message.Assistant:
-			blocks := []anthropic.ContentBlockParamUnion{}
-
-			// Add thinking blocks first if present (required when thinking is enabled with tool use)
-			if reasoningContent := msg.ReasoningContent(); reasoningContent.Thinking != "" {
-				thinkingBlock := anthropic.NewThinkingBlock(reasoningContent.Signature, reasoningContent.Thinking)
-				blocks = append(blocks, thinkingBlock)
-			}
-
-			if msg.Content().String() != "" {
-				content := anthropic.NewTextBlock(msg.Content().String())
-				if cache && !a.providerOptions.disableCache {
-					content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
-						Type: "ephemeral",
-					}
-				}
-				blocks = append(blocks, content)
-			}
-
-			for _, toolCall := range msg.ToolCalls() {
-				if !toolCall.Finished {
-					continue
-				}
-				var inputMap map[string]any
-				err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
-				if err != nil {
-					continue
-				}
-				blocks = append(blocks, anthropic.NewToolUseBlock(toolCall.ID, inputMap, toolCall.Name))
-			}
-
-			if len(blocks) == 0 {
-				continue
-			}
-			anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
-
-		case message.Tool:
-			results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults()))
-			for i, toolResult := range msg.ToolResults() {
-				results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
-			}
-			anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...))
-		}
-	}
-	return anthropicMessages
-}
-
-func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
-	if len(tools) == 0 {
-		return nil
-	}
-	anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
-
-	for i, tool := range tools {
-		info := tool.Info()
-		toolParam := anthropic.ToolParam{
-			Name:        info.Name,
-			Description: anthropic.String(info.Description),
-			InputSchema: anthropic.ToolInputSchemaParam{
-				Properties: info.Parameters,
-				Required:   info.Required,
-			},
-		}
-
-		if i == len(tools)-1 && !a.providerOptions.disableCache {
-			toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
-				Type: "ephemeral",
-			}
-		}
-
-		anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
-	}
-
-	return anthropicTools
-}
-
-func (a *anthropicClient) finishReason(reason string) message.FinishReason {
-	switch reason {
-	case "end_turn":
-		return message.FinishReasonEndTurn
-	case "max_tokens":
-		return message.FinishReasonMaxTokens
-	case "tool_use":
-		return message.FinishReasonToolUse
-	case "stop_sequence":
-		return message.FinishReasonEndTurn
-	default:
-		return message.FinishReasonUnknown
-	}
-}
-
-func (a *anthropicClient) isThinkingEnabled() bool {
-	cfg := config.Get()
-	modelConfig := cfg.Models[config.SelectedModelTypeLarge]
-	if a.providerOptions.modelType == config.SelectedModelTypeSmall {
-		modelConfig = cfg.Models[config.SelectedModelTypeSmall]
-	}
-	return a.Model().CanReason && modelConfig.Think
-}
-
-func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams {
-	model := a.providerOptions.model(a.providerOptions.modelType)
-	var thinkingParam anthropic.ThinkingConfigParamUnion
-	cfg := config.Get()
-	modelConfig := cfg.Models[config.SelectedModelTypeLarge]
-	if a.providerOptions.modelType == config.SelectedModelTypeSmall {
-		modelConfig = cfg.Models[config.SelectedModelTypeSmall]
-	}
-	temperature := anthropic.Float(0)
-
-	maxTokens := model.DefaultMaxTokens
-	if modelConfig.MaxTokens > 0 {
-		maxTokens = modelConfig.MaxTokens
-	}
-	if a.isThinkingEnabled() {
-		thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(maxTokens) * 0.8))
-		temperature = anthropic.Float(1)
-	}
-	// Override max tokens if set in provider options
-	if a.providerOptions.maxTokens > 0 {
-		maxTokens = a.providerOptions.maxTokens
-	}
-
-	// Use adjusted max tokens if context limit was hit
-	if a.adjustedMaxTokens > 0 {
-		maxTokens = int64(a.adjustedMaxTokens)
-	}
-
-	systemBlocks := []anthropic.TextBlockParam{}
-
-	// Add custom system prompt prefix if configured
-	if a.providerOptions.systemPromptPrefix != "" {
-		systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
-			Text: a.providerOptions.systemPromptPrefix,
-		})
-	}
-
-	systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
-		Text: a.providerOptions.systemMessage,
-		CacheControl: anthropic.CacheControlEphemeralParam{
-			Type: "ephemeral",
-		},
-	})
-
-	return anthropic.MessageNewParams{
-		Model:       anthropic.Model(model.ID),
-		MaxTokens:   maxTokens,
-		Temperature: temperature,
-		Messages:    messages,
-		Tools:       tools,
-		Thinking:    thinkingParam,
-		System:      systemBlocks,
-	}
-}
-
-func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
-	attempts := 0
-	for {
-		attempts++
-		// Prepare messages on each attempt in case max_tokens was adjusted
-		preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
-
-		var opts []option.RequestOption
-		if a.isThinkingEnabled() {
-			opts = append(opts, option.WithHeaderAdd("anthropic-beta", "interleaved-thinking-2025-05-14"))
-		}
-		anthropicResponse, err := a.client.Messages.New(
-			ctx,
-			preparedMessages,
-			opts...,
-		)
-		// If there is an error we are going to see if we can retry the call
-		if err != nil {
-			retry, after, retryErr := a.shouldRetry(attempts, err)
-			if retryErr != nil {
-				return nil, retryErr
-			}
-			if retry {
-				slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
-				select {
-				case <-ctx.Done():
-					return nil, ctx.Err()
-				case <-time.After(time.Duration(after) * time.Millisecond):
-					continue
-				}
-			}
-			return nil, retryErr
-		}
-
-		content := ""
-		for _, block := range anthropicResponse.Content {
-			if text, ok := block.AsAny().(anthropic.TextBlock); ok {
-				content += text.Text
-			}
-		}
-
-		return &ProviderResponse{
-			Content:   content,
-			ToolCalls: a.toolCalls(*anthropicResponse),
-			Usage:     a.usage(*anthropicResponse),
-		}, nil
-	}
-}
-
-func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
-	attempts := 0
-	eventChan := make(chan ProviderEvent)
-	go func() {
-		for {
-			attempts++
-			// Prepare messages on each attempt in case max_tokens was adjusted
-			preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
-
-			var opts []option.RequestOption
-			if a.isThinkingEnabled() {
-				opts = append(opts, option.WithHeaderAdd("anthropic-beta", "interleaved-thinking-2025-05-14"))
-			}
-
-			anthropicStream := a.client.Messages.NewStreaming(
-				ctx,
-				preparedMessages,
-				opts...,
-			)
-			accumulatedMessage := anthropic.Message{}
-
-			currentToolCallID := ""
-			for anthropicStream.Next() {
-				event := anthropicStream.Current()
-				err := accumulatedMessage.Accumulate(event)
-				if err != nil {
-					slog.Warn("Error accumulating message", "error", err)
-					continue
-				}
-
-				switch event := event.AsAny().(type) {
-				case anthropic.ContentBlockStartEvent:
-					switch event.ContentBlock.Type {
-					case "text":
-						eventChan <- ProviderEvent{Type: EventContentStart}
-					case "tool_use":
-						currentToolCallID = event.ContentBlock.ID
-						eventChan <- ProviderEvent{
-							Type: EventToolUseStart,
-							ToolCall: &message.ToolCall{
-								ID:       event.ContentBlock.ID,
-								Name:     event.ContentBlock.Name,
-								Finished: false,
-							},
-						}
-					}
-
-				case anthropic.ContentBlockDeltaEvent:
-					if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
-						eventChan <- ProviderEvent{
-							Type:     EventThinkingDelta,
-							Thinking: event.Delta.Thinking,
-						}
-					} else if event.Delta.Type == "signature_delta" && event.Delta.Signature != "" {
-						eventChan <- ProviderEvent{
-							Type:      EventSignatureDelta,
-							Signature: event.Delta.Signature,
-						}
-					} else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
-						eventChan <- ProviderEvent{
-							Type:    EventContentDelta,
-							Content: event.Delta.Text,
-						}
-					} else if event.Delta.Type == "input_json_delta" {
-						if currentToolCallID != "" {
-							eventChan <- ProviderEvent{
-								Type: EventToolUseDelta,
-								ToolCall: &message.ToolCall{
-									ID:       currentToolCallID,
-									Finished: false,
-									Input:    event.Delta.PartialJSON,
-								},
-							}
-						}
-					}
-				case anthropic.ContentBlockStopEvent:
-					if currentToolCallID != "" {
-						eventChan <- ProviderEvent{
-							Type: EventToolUseStop,
-							ToolCall: &message.ToolCall{
-								ID: currentToolCallID,
-							},
-						}
-						currentToolCallID = ""
-					} else {
-						eventChan <- ProviderEvent{Type: EventContentStop}
-					}
-
-				case anthropic.MessageStopEvent:
-					content := ""
-					for _, block := range accumulatedMessage.Content {
-						if text, ok := block.AsAny().(anthropic.TextBlock); ok {
-							content += text.Text
-						}
-					}
-
-					eventChan <- ProviderEvent{
-						Type: EventComplete,
-						Response: &ProviderResponse{
-							Content:      content,
-							ToolCalls:    a.toolCalls(accumulatedMessage),
-							Usage:        a.usage(accumulatedMessage),
-							FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
-						},
-						Content: content,
-					}
-				}
-			}
-
-			err := anthropicStream.Err()
-			if err == nil || errors.Is(err, io.EOF) {
-				close(eventChan)
-				return
-			}
-
-			// If there is an error we are going to see if we can retry the call
-			retry, after, retryErr := a.shouldRetry(attempts, err)
-			if retryErr != nil {
-				eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
-				close(eventChan)
-				return
-			}
-			if retry {
-				slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
-				select {
-				case <-ctx.Done():
-					// context cancelled
-					if ctx.Err() != nil {
-						eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
-					}
-					close(eventChan)
-					return
-				case <-time.After(time.Duration(after) * time.Millisecond):
-					continue
-				}
-			}
-			if ctx.Err() != nil {
-				eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
-			}
-
-			close(eventChan)
-			return
-		}
-	}()
-	return eventChan
-}
-
-func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, error) {
-	var apiErr *anthropic.Error
-	if !errors.As(err, &apiErr) {
-		return false, 0, err
-	}
-
-	if attempts > maxRetries {
-		return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
-	}
-
-	if apiErr.StatusCode == http.StatusUnauthorized {
-		prev := a.providerOptions.apiKey
-		// in case the key comes from a script, we try to re-evaluate it.
-		a.providerOptions.apiKey, err = config.Get().Resolve(a.providerOptions.config.APIKey)
-		if err != nil {
-			return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
-		}
-		// if it didn't change, do not retry.
-		if prev == a.providerOptions.apiKey {
-			return false, 0, err
-		}
-		a.client = createAnthropicClient(a.providerOptions, a.tp)
-		return true, 0, nil
-	}
-
-	// Handle context limit exceeded error (400 Bad Request)
-	if apiErr.StatusCode == http.StatusBadRequest {
-		if adjusted, ok := a.handleContextLimitError(apiErr); ok {
-			a.adjustedMaxTokens = adjusted
-			slog.Debug("Adjusted max_tokens due to context limit", "new_max_tokens", adjusted)
-			return true, 0, nil
-		}
-	}
-
-	isOverloaded := strings.Contains(apiErr.Error(), "overloaded") || strings.Contains(apiErr.Error(), "rate limit exceeded")
-	// 529 (unofficial): The service is overloaded
-	if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != 529 && !isOverloaded {
-		return false, 0, err
-	}
-
-	retryMs := 0
-	retryAfterValues := apiErr.Response.Header.Values("Retry-After")
-
-	backoffMs := 2000 * (1 << (attempts - 1))
-	jitterMs := int(float64(backoffMs) * 0.2)
-	retryMs = backoffMs + jitterMs
-	if len(retryAfterValues) > 0 {
-		if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
-			retryMs = retryMs * 1000
-		}
-	}
-	return true, int64(retryMs), nil
-}
-
-// handleContextLimitError parses context limit error and returns adjusted max_tokens
-func (a *anthropicClient) handleContextLimitError(apiErr *anthropic.Error) (int, bool) {
-	// Parse error message like: "input length and max_tokens exceed context limit: 154978 + 50000 > 200000"
-	errorMsg := apiErr.Error()
-
-	matches := contextLimitRegex.FindStringSubmatch(errorMsg)
-
-	if len(matches) != 4 {
-		return 0, false
-	}
-
-	inputTokens, err1 := strconv.Atoi(matches[1])
-	contextLimit, err2 := strconv.Atoi(matches[3])
-
-	if err1 != nil || err2 != nil {
-		return 0, false
-	}
-
-	// Calculate safe max_tokens with a buffer of 1000 tokens
-	safeMaxTokens := contextLimit - inputTokens - 1000
-
-	// Ensure we don't go below a minimum threshold
-	safeMaxTokens = max(safeMaxTokens, 1000)
-
-	return safeMaxTokens, true
-}
-
-func (a *anthropicClient) toolCalls(msg anthropic.Message) []message.ToolCall {
-	var toolCalls []message.ToolCall
-
-	for _, block := range msg.Content {
-		switch variant := block.AsAny().(type) {
-		case anthropic.ToolUseBlock:
-			toolCall := message.ToolCall{
-				ID:       variant.ID,
-				Name:     variant.Name,
-				Input:    string(variant.Input),
-				Type:     string(variant.Type),
-				Finished: true,
-			}
-			toolCalls = append(toolCalls, toolCall)
-		}
-	}
-
-	return toolCalls
-}
-
-func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
-	return TokenUsage{
-		InputTokens:         msg.Usage.InputTokens,
-		OutputTokens:        msg.Usage.OutputTokens,
-		CacheCreationTokens: msg.Usage.CacheCreationInputTokens,
-		CacheReadTokens:     msg.Usage.CacheReadInputTokens,
-	}
-}
-
-func (a *anthropicClient) Model() catwalk.Model {
-	return a.providerOptions.model(a.providerOptions.modelType)
-}
diff --git a/internal/llm/provider/azure.go b/internal/llm/provider/azure.go
deleted file mode 100644
index 9042d668..00000000
--- a/internal/llm/provider/azure.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package provider
-
-import (
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/log"
-	"github.com/openai/openai-go"
-	"github.com/openai/openai-go/azure"
-	"github.com/openai/openai-go/option"
-)
-
-type azureClient struct {
-	*openaiClient
-}
-
-type AzureClient ProviderClient
-
-func newAzureClient(opts providerClientOptions) AzureClient {
-	apiVersion := opts.extraParams["apiVersion"]
-	if apiVersion == "" {
-		apiVersion = "2025-01-01-preview"
-	}
-
-	reqOpts := []option.RequestOption{
-		azure.WithEndpoint(opts.baseURL, apiVersion),
-	}
-
-	if config.Get().Options.Debug {
-		httpClient := log.NewHTTPClient()
-		reqOpts = append(reqOpts, option.WithHTTPClient(httpClient))
-	}
-
-	reqOpts = append(reqOpts, azure.WithAPIKey(opts.apiKey))
-	base := &openaiClient{
-		providerOptions: opts,
-		client:          openai.NewClient(reqOpts...),
-	}
-
-	return &azureClient{openaiClient: base}
-}
diff --git a/internal/llm/provider/bedrock.go b/internal/llm/provider/bedrock.go
deleted file mode 100644
index 526d11b5..00000000
--- a/internal/llm/provider/bedrock.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package provider
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"strings"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/message"
-)
-
-type bedrockClient struct {
-	providerOptions providerClientOptions
-	childProvider   ProviderClient
-}
-
-type BedrockClient ProviderClient
-
-func newBedrockClient(opts providerClientOptions) BedrockClient {
-	// Get AWS region from environment
-	region := opts.extraParams["region"]
-	if region == "" {
-		region = "us-east-1" // default region
-	}
-	if len(region) < 2 {
-		return &bedrockClient{
-			providerOptions: opts,
-			childProvider:   nil, // Will cause an error when used
-		}
-	}
-
-	opts.model = func(modelType config.SelectedModelType) catwalk.Model {
-		model := config.Get().GetModelByType(modelType)
-
-		// Prefix the model name with region
-		regionPrefix := region[:2]
-		modelName := model.ID
-		model.ID = fmt.Sprintf("%s.%s", regionPrefix, modelName)
-		return *model
-	}
-
-	model := opts.model(opts.modelType)
-
-	// Determine which provider to use based on the model
-	if strings.Contains(string(model.ID), "anthropic") {
-		// Create Anthropic client with Bedrock configuration
-		anthropicOpts := opts
-		// TODO: later find a way to check if the AWS account has caching enabled
-		opts.disableCache = true // Disable cache for Bedrock
-		return &bedrockClient{
-			providerOptions: opts,
-			childProvider:   newAnthropicClient(anthropicOpts, AnthropicClientTypeBedrock),
-		}
-	}
-
-	// Return client with nil childProvider if model is not supported
-	// This will cause an error when used
-	return &bedrockClient{
-		providerOptions: opts,
-		childProvider:   nil,
-	}
-}
-
-func (b *bedrockClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
-	if b.childProvider == nil {
-		return nil, errors.New("unsupported model for bedrock provider")
-	}
-	return b.childProvider.send(ctx, messages, tools)
-}
-
-func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
-	eventChan := make(chan ProviderEvent)
-
-	if b.childProvider == nil {
-		go func() {
-			eventChan <- ProviderEvent{
-				Type:  EventError,
-				Error: errors.New("unsupported model for bedrock provider"),
-			}
-			close(eventChan)
-		}()
-		return eventChan
-	}
-
-	return b.childProvider.stream(ctx, messages, tools)
-}
-
-func (b *bedrockClient) Model() catwalk.Model {
-	return b.providerOptions.model(b.providerOptions.modelType)
-}
diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go
deleted file mode 100644
index a846d8d5..00000000
--- a/internal/llm/provider/gemini.go
+++ /dev/null
@@ -1,579 +0,0 @@
-package provider
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/log"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/google/uuid"
-	"google.golang.org/genai"
-)
-
-type geminiClient struct {
-	providerOptions providerClientOptions
-	client          *genai.Client
-}
-
-type GeminiClient ProviderClient
-
-func newGeminiClient(opts providerClientOptions) GeminiClient {
-	client, err := createGeminiClient(opts)
-	if err != nil {
-		slog.Error("Failed to create Gemini client", "error", err)
-		return nil
-	}
-
-	return &geminiClient{
-		providerOptions: opts,
-		client:          client,
-	}
-}
-
-func createGeminiClient(opts providerClientOptions) (*genai.Client, error) {
-	cc := &genai.ClientConfig{
-		APIKey:  opts.apiKey,
-		Backend: genai.BackendGeminiAPI,
-	}
-	if opts.baseURL != "" {
-		resolvedBaseURL, err := config.Get().Resolve(opts.baseURL)
-		if err == nil && resolvedBaseURL != "" {
-			cc.HTTPOptions = genai.HTTPOptions{
-				BaseURL: resolvedBaseURL,
-			}
-		}
-	}
-	if config.Get().Options.Debug {
-		cc.HTTPClient = log.NewHTTPClient()
-	}
-	client, err := genai.NewClient(context.Background(), cc)
-	if err != nil {
-		return nil, err
-	}
-	return client, nil
-}
-
-func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content {
-	var history []*genai.Content
-	for _, msg := range messages {
-		switch msg.Role {
-		case message.User:
-			var parts []*genai.Part
-			parts = append(parts, &genai.Part{Text: msg.Content().String()})
-			for _, binaryContent := range msg.BinaryContent() {
-				parts = append(parts, &genai.Part{InlineData: &genai.Blob{
-					MIMEType: binaryContent.MIMEType,
-					Data:     binaryContent.Data,
-				}})
-			}
-			history = append(history, &genai.Content{
-				Parts: parts,
-				Role:  genai.RoleUser,
-			})
-		case message.Assistant:
-			var assistantParts []*genai.Part
-
-			if msg.Content().String() != "" {
-				assistantParts = append(assistantParts, &genai.Part{Text: msg.Content().String()})
-			}
-
-			if len(msg.ToolCalls()) > 0 {
-				for _, call := range msg.ToolCalls() {
-					if !call.Finished {
-						continue
-					}
-					args, _ := parseJSONToMap(call.Input)
-					assistantParts = append(assistantParts, &genai.Part{
-						FunctionCall: &genai.FunctionCall{
-							Name: call.Name,
-							Args: args,
-						},
-					})
-				}
-			}
-
-			if len(assistantParts) > 0 {
-				history = append(history, &genai.Content{
-					Role:  genai.RoleModel,
-					Parts: assistantParts,
-				})
-			}
-
-		case message.Tool:
-			var toolParts []*genai.Part
-			for _, result := range msg.ToolResults() {
-				response := map[string]any{"result": result.Content}
-				parsed, err := parseJSONToMap(result.Content)
-				if err == nil {
-					response = parsed
-				}
-
-				var toolCall message.ToolCall
-				for _, m := range messages {
-					if m.Role == message.Assistant {
-						for _, call := range m.ToolCalls() {
-							if call.ID == result.ToolCallID {
-								toolCall = call
-								break
-							}
-						}
-					}
-				}
-
-				toolParts = append(toolParts, &genai.Part{
-					FunctionResponse: &genai.FunctionResponse{
-						Name:     toolCall.Name,
-						Response: response,
-					},
-				})
-			}
-			if len(toolParts) > 0 {
-				history = append(history, &genai.Content{
-					Parts: toolParts,
-					Role:  genai.RoleUser,
-				})
-			}
-		}
-	}
-
-	return history
-}
-
-func (g *geminiClient) convertTools(tools []tools.BaseTool) []*genai.Tool {
-	geminiTool := &genai.Tool{}
-	geminiTool.FunctionDeclarations = make([]*genai.FunctionDeclaration, 0, len(tools))
-
-	for _, tool := range tools {
-		info := tool.Info()
-		declaration := &genai.FunctionDeclaration{
-			Name:        info.Name,
-			Description: info.Description,
-			Parameters: &genai.Schema{
-				Type:       genai.TypeObject,
-				Properties: convertSchemaProperties(info.Parameters),
-				Required:   info.Required,
-			},
-		}
-
-		geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, declaration)
-	}
-
-	return []*genai.Tool{geminiTool}
-}
-
-func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishReason {
-	switch reason {
-	case genai.FinishReasonStop:
-		return message.FinishReasonEndTurn
-	case genai.FinishReasonMaxTokens:
-		return message.FinishReasonMaxTokens
-	default:
-		return message.FinishReasonUnknown
-	}
-}
-
-func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
-	// Convert messages
-	geminiMessages := g.convertMessages(messages)
-	model := g.providerOptions.model(g.providerOptions.modelType)
-	cfg := config.Get()
-
-	modelConfig := cfg.Models[config.SelectedModelTypeLarge]
-	if g.providerOptions.modelType == config.SelectedModelTypeSmall {
-		modelConfig = cfg.Models[config.SelectedModelTypeSmall]
-	}
-
-	maxTokens := model.DefaultMaxTokens
-	if modelConfig.MaxTokens > 0 {
-		maxTokens = modelConfig.MaxTokens
-	}
-	systemMessage := g.providerOptions.systemMessage
-	if g.providerOptions.systemPromptPrefix != "" {
-		systemMessage = g.providerOptions.systemPromptPrefix + "\n" + systemMessage
-	}
-	history := geminiMessages[:len(geminiMessages)-1] // All but last message
-	lastMsg := geminiMessages[len(geminiMessages)-1]
-	config := &genai.GenerateContentConfig{
-		MaxOutputTokens: int32(maxTokens),
-		SystemInstruction: &genai.Content{
-			Parts: []*genai.Part{{Text: systemMessage}},
-		},
-	}
-	config.Tools = g.convertTools(tools)
-	chat, _ := g.client.Chats.Create(ctx, model.ID, config, history)
-
-	attempts := 0
-	for {
-		attempts++
-		var toolCalls []message.ToolCall
-
-		var lastMsgParts []genai.Part
-		for _, part := range lastMsg.Parts {
-			lastMsgParts = append(lastMsgParts, *part)
-		}
-		resp, err := chat.SendMessage(ctx, lastMsgParts...)
-		// If there is an error we are going to see if we can retry the call
-		if err != nil {
-			retry, after, retryErr := g.shouldRetry(attempts, err)
-			if retryErr != nil {
-				return nil, retryErr
-			}
-			if retry {
-				slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
-				select {
-				case <-ctx.Done():
-					return nil, ctx.Err()
-				case <-time.After(time.Duration(after) * time.Millisecond):
-					continue
-				}
-			}
-			return nil, retryErr
-		}
-
-		content := ""
-
-		if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
-			for _, part := range resp.Candidates[0].Content.Parts {
-				switch {
-				case part.Text != "":
-					content = string(part.Text)
-				case part.FunctionCall != nil:
-					id := "call_" + uuid.New().String()
-					args, _ := json.Marshal(part.FunctionCall.Args)
-					toolCalls = append(toolCalls, message.ToolCall{
-						ID:       id,
-						Name:     part.FunctionCall.Name,
-						Input:    string(args),
-						Type:     "function",
-						Finished: true,
-					})
-				}
-			}
-		}
-		finishReason := message.FinishReasonEndTurn
-		if len(resp.Candidates) > 0 {
-			finishReason = g.finishReason(resp.Candidates[0].FinishReason)
-		}
-		if len(toolCalls) > 0 {
-			finishReason = message.FinishReasonToolUse
-		}
-
-		return &ProviderResponse{
-			Content:      content,
-			ToolCalls:    toolCalls,
-			Usage:        g.usage(resp),
-			FinishReason: finishReason,
-		}, nil
-	}
-}
-
-func (g *geminiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
-	// Convert messages
-	geminiMessages := g.convertMessages(messages)
-
-	model := g.providerOptions.model(g.providerOptions.modelType)
-	cfg := config.Get()
-
-	modelConfig := cfg.Models[config.SelectedModelTypeLarge]
-	if g.providerOptions.modelType == config.SelectedModelTypeSmall {
-		modelConfig = cfg.Models[config.SelectedModelTypeSmall]
-	}
-	maxTokens := model.DefaultMaxTokens
-	if modelConfig.MaxTokens > 0 {
-		maxTokens = modelConfig.MaxTokens
-	}
-
-	// Override max tokens if set in provider options
-	if g.providerOptions.maxTokens > 0 {
-		maxTokens = g.providerOptions.maxTokens
-	}
-	systemMessage := g.providerOptions.systemMessage
-	if g.providerOptions.systemPromptPrefix != "" {
-		systemMessage = g.providerOptions.systemPromptPrefix + "\n" + systemMessage
-	}
-	history := geminiMessages[:len(geminiMessages)-1] // All but last message
-	lastMsg := geminiMessages[len(geminiMessages)-1]
-	config := &genai.GenerateContentConfig{
-		MaxOutputTokens: int32(maxTokens),
-		SystemInstruction: &genai.Content{
-			Parts: []*genai.Part{{Text: systemMessage}},
-		},
-	}
-	config.Tools = g.convertTools(tools)
-	chat, _ := g.client.Chats.Create(ctx, model.ID, config, history)
-
-	attempts := 0
-	eventChan := make(chan ProviderEvent)
-
-	go func() {
-		defer close(eventChan)
-
-		for {
-			attempts++
-
-			currentContent := ""
-			toolCalls := []message.ToolCall{}
-			var finalResp *genai.GenerateContentResponse
-
-			eventChan <- ProviderEvent{Type: EventContentStart}
-
-			var lastMsgParts []genai.Part
-
-			for _, part := range lastMsg.Parts {
-				lastMsgParts = append(lastMsgParts, *part)
-			}
-
-			for resp, err := range chat.SendMessageStream(ctx, lastMsgParts...) {
-				if err != nil {
-					retry, after, retryErr := g.shouldRetry(attempts, err)
-					if retryErr != nil {
-						eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
-						return
-					}
-					if retry {
-						slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
-						select {
-						case <-ctx.Done():
-							if ctx.Err() != nil {
-								eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
-							}
-
-							return
-						case <-time.After(time.Duration(after) * time.Millisecond):
-							continue
-						}
-					} else {
-						eventChan <- ProviderEvent{Type: EventError, Error: err}
-						return
-					}
-				}
-
-				finalResp = resp
-
-				if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
-					for _, part := range resp.Candidates[0].Content.Parts {
-						switch {
-						case part.Text != "":
-							delta := string(part.Text)
-							if delta != "" {
-								eventChan <- ProviderEvent{
-									Type:    EventContentDelta,
-									Content: delta,
-								}
-								currentContent += delta
-							}
-						case part.FunctionCall != nil:
-							id := "call_" + uuid.New().String()
-							args, _ := json.Marshal(part.FunctionCall.Args)
-							newCall := message.ToolCall{
-								ID:       id,
-								Name:     part.FunctionCall.Name,
-								Input:    string(args),
-								Type:     "function",
-								Finished: true,
-							}
-
-							toolCalls = append(toolCalls, newCall)
-						}
-					}
-				} else {
-					// no content received
-					break
-				}
-			}
-
-			eventChan <- ProviderEvent{Type: EventContentStop}
-
-			if finalResp != nil {
-				finishReason := message.FinishReasonEndTurn
-				if len(finalResp.Candidates) > 0 {
-					finishReason = g.finishReason(finalResp.Candidates[0].FinishReason)
-				}
-				if len(toolCalls) > 0 {
-					finishReason = message.FinishReasonToolUse
-				}
-				eventChan <- ProviderEvent{
-					Type: EventComplete,
-					Response: &ProviderResponse{
-						Content:      currentContent,
-						ToolCalls:    toolCalls,
-						Usage:        g.usage(finalResp),
-						FinishReason: finishReason,
-					},
-				}
-				return
-			} else {
-				eventChan <- ProviderEvent{
-					Type:  EventError,
-					Error: errors.New("no content received"),
-				}
-			}
-		}
-	}()
-
-	return eventChan
-}
-
-func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
-	// Check if error is a rate limit error
-	if attempts > maxRetries {
-		return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
-	}
-
-	// Gemini doesn't have a standard error type we can check against
-	// So we'll check the error message for rate limit indicators
-	if errors.Is(err, io.EOF) {
-		return false, 0, err
-	}
-
-	errMsg := err.Error()
-	isRateLimit := contains(errMsg, "rate limit", "quota exceeded", "too many requests")
-
-	// Check for token expiration (401 Unauthorized)
-	if contains(errMsg, "unauthorized", "invalid api key", "api key expired") {
-		prev := g.providerOptions.apiKey
-		// in case the key comes from a script, we try to re-evaluate it.
-		g.providerOptions.apiKey, err = config.Get().Resolve(g.providerOptions.config.APIKey)
-		if err != nil {
-			return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
-		}
-		// if it didn't change, do not retry.
-		if prev == g.providerOptions.apiKey {
-			return false, 0, err
-		}
-		g.client, err = createGeminiClient(g.providerOptions)
-		if err != nil {
-			return false, 0, fmt.Errorf("failed to create Gemini client after API key refresh: %w", err)
-		}
-		return true, 0, nil
-	}
-
-	// Check for common rate limit error messages
-
-	if !isRateLimit {
-		return false, 0, err
-	}
-
-	// Calculate backoff with jitter
-	backoffMs := 2000 * (1 << (attempts - 1))
-	jitterMs := int(float64(backoffMs) * 0.2)
-	retryMs := backoffMs + jitterMs
-
-	return true, int64(retryMs), nil
-}
-
-func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
-	if resp == nil || resp.UsageMetadata == nil {
-		return TokenUsage{}
-	}
-
-	return TokenUsage{
-		InputTokens:         int64(resp.UsageMetadata.PromptTokenCount),
-		OutputTokens:        int64(resp.UsageMetadata.CandidatesTokenCount),
-		CacheCreationTokens: 0, // Not directly provided by Gemini
-		CacheReadTokens:     int64(resp.UsageMetadata.CachedContentTokenCount),
-	}
-}
-
-func (g *geminiClient) Model() catwalk.Model {
-	return g.providerOptions.model(g.providerOptions.modelType)
-}
-
-// Helper functions
-func parseJSONToMap(jsonStr string) (map[string]any, error) {
-	var result map[string]any
-	err := json.Unmarshal([]byte(jsonStr), &result)
-	return result, err
-}
-
-func convertSchemaProperties(parameters map[string]any) map[string]*genai.Schema {
-	properties := make(map[string]*genai.Schema)
-
-	for name, param := range parameters {
-		properties[name] = convertToSchema(param)
-	}
-
-	return properties
-}
-
-func convertToSchema(param any) *genai.Schema {
-	schema := &genai.Schema{Type: genai.TypeString}
-
-	paramMap, ok := param.(map[string]any)
-	if !ok {
-		return schema
-	}
-
-	if desc, ok := paramMap["description"].(string); ok {
-		schema.Description = desc
-	}
-
-	typeVal, hasType := paramMap["type"]
-	if !hasType {
-		return schema
-	}
-
-	typeStr, ok := typeVal.(string)
-	if !ok {
-		return schema
-	}
-
-	schema.Type = mapJSONTypeToGenAI(typeStr)
-
-	switch typeStr {
-	case "array":
-		schema.Items = processArrayItems(paramMap)
-	case "object":
-		if props, ok := paramMap["properties"].(map[string]any); ok {
-			schema.Properties = convertSchemaProperties(props)
-		}
-	}
-
-	return schema
-}
-
-func processArrayItems(paramMap map[string]any) *genai.Schema {
-	items, ok := paramMap["items"].(map[string]any)
-	if !ok {
-		return nil
-	}
-
-	return convertToSchema(items)
-}
-
-func mapJSONTypeToGenAI(jsonType string) genai.Type {
-	switch jsonType {
-	case "string":
-		return genai.TypeString
-	case "number":
-		return genai.TypeNumber
-	case "integer":
-		return genai.TypeInteger
-	case "boolean":
-		return genai.TypeBoolean
-	case "array":
-		return genai.TypeArray
-	case "object":
-		return genai.TypeObject
-	default:
-		return genai.TypeString // Default to string for unknown types
-	}
-}
-
-func contains(s string, substrs ...string) bool {
-	for _, substr := range substrs {
-		if strings.Contains(strings.ToLower(s), strings.ToLower(substr)) {
-			return true
-		}
-	}
-	return false
-}
diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go
deleted file mode 100644
index 3e92e077..00000000
--- a/internal/llm/provider/openai.go
+++ /dev/null
@@ -1,604 +0,0 @@
-package provider
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"net/http"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/log"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/google/uuid"
-	"github.com/openai/openai-go"
-	"github.com/openai/openai-go/option"
-	"github.com/openai/openai-go/packages/param"
-	"github.com/openai/openai-go/shared"
-)
-
-type openaiClient struct {
-	providerOptions providerClientOptions
-	client          openai.Client
-}
-
-type OpenAIClient ProviderClient
-
-func newOpenAIClient(opts providerClientOptions) OpenAIClient {
-	return &openaiClient{
-		providerOptions: opts,
-		client:          createOpenAIClient(opts),
-	}
-}
-
-func createOpenAIClient(opts providerClientOptions) openai.Client {
-	openaiClientOptions := []option.RequestOption{}
-	if opts.apiKey != "" {
-		openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey))
-	}
-	if opts.baseURL != "" {
-		resolvedBaseURL, err := config.Get().Resolve(opts.baseURL)
-		if err == nil && resolvedBaseURL != "" {
-			openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(resolvedBaseURL))
-		}
-	}
-
-	if config.Get().Options.Debug {
-		httpClient := log.NewHTTPClient()
-		openaiClientOptions = append(openaiClientOptions, option.WithHTTPClient(httpClient))
-	}
-
-	for key, value := range opts.extraHeaders {
-		openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
-	}
-
-	for extraKey, extraValue := range opts.extraBody {
-		openaiClientOptions = append(openaiClientOptions, option.WithJSONSet(extraKey, extraValue))
-	}
-
-	return openai.NewClient(openaiClientOptions...)
-}
-
-func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
-	isAnthropicModel := o.providerOptions.config.ID == string(catwalk.InferenceProviderOpenRouter) && strings.HasPrefix(o.Model().ID, "anthropic/")
-	// Add system message first
-	systemMessage := o.providerOptions.systemMessage
-	if o.providerOptions.systemPromptPrefix != "" {
-		systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
-	}
-
-	system := openai.SystemMessage(systemMessage)
-	if isAnthropicModel && !o.providerOptions.disableCache {
-		systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
-		systemTextBlock.SetExtraFields(
-			map[string]any{
-				"cache_control": map[string]string{
-					"type": "ephemeral",
-				},
-			},
-		)
-		var content []openai.ChatCompletionContentPartTextParam
-		content = append(content, systemTextBlock)
-		system = openai.SystemMessage(content)
-	}
-	openaiMessages = append(openaiMessages, system)
-
-	for i, msg := range messages {
-		cache := false
-		if i > len(messages)-3 {
-			cache = true
-		}
-		switch msg.Role {
-		case message.User:
-			var content []openai.ChatCompletionContentPartUnionParam
-
-			textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
-			content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
-			hasBinaryContent := false
-			for _, binaryContent := range msg.BinaryContent() {
-				hasBinaryContent = true
-				imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
-				imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
-
-				content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
-			}
-			if cache && !o.providerOptions.disableCache && isAnthropicModel {
-				textBlock.SetExtraFields(map[string]any{
-					"cache_control": map[string]string{
-						"type": "ephemeral",
-					},
-				})
-			}
-			if hasBinaryContent || (isAnthropicModel && !o.providerOptions.disableCache) {
-				openaiMessages = append(openaiMessages, openai.UserMessage(content))
-			} else {
-				openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
-			}
-
-		case message.Assistant:
-			assistantMsg := openai.ChatCompletionAssistantMessageParam{
-				Role: "assistant",
-			}
-
-			// Only include finished tool calls; interrupted tool calls must not be resent.
-			if len(msg.ToolCalls()) > 0 {
-				finished := make([]message.ToolCall, 0, len(msg.ToolCalls()))
-				for _, call := range msg.ToolCalls() {
-					if call.Finished {
-						finished = append(finished, call)
-					}
-				}
-				if len(finished) > 0 {
-					assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(finished))
-					for i, call := range finished {
-						assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
-							ID:   call.ID,
-							Type: "function",
-							Function: openai.ChatCompletionMessageToolCallFunctionParam{
-								Name:      call.Name,
-								Arguments: call.Input,
-							},
-						}
-					}
-				}
-			}
-			if msg.Content().String() != "" {
-				assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
-					OfString: param.NewOpt(msg.Content().Text),
-				}
-			}
-
-			if cache && !o.providerOptions.disableCache && isAnthropicModel {
-				assistantMsg.SetExtraFields(map[string]any{
-					"cache_control": map[string]string{
-						"type": "ephemeral",
-					},
-				})
-			}
-			// Skip empty assistant messages (no content and no finished tool calls)
-			if msg.Content().String() == "" && len(assistantMsg.ToolCalls) == 0 {
-				continue
-			}
-
-			openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
-				OfAssistant: &assistantMsg,
-			})
-
-		case message.Tool:
-			for _, result := range msg.ToolResults() {
-				openaiMessages = append(openaiMessages,
-					openai.ToolMessage(result.Content, result.ToolCallID),
-				)
-			}
-		}
-	}
-
-	return openaiMessages
-}
-
-func (o *openaiClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
-	openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
-
-	for i, tool := range tools {
-		info := tool.Info()
-		openaiTools[i] = openai.ChatCompletionToolParam{
-			Function: openai.FunctionDefinitionParam{
-				Name:        info.Name,
-				Description: openai.String(info.Description),
-				Parameters: openai.FunctionParameters{
-					"type":       "object",
-					"properties": info.Parameters,
-					"required":   info.Required,
-				},
-			},
-		}
-	}
-
-	return openaiTools
-}
-
-func (o *openaiClient) finishReason(reason string) message.FinishReason {
-	switch reason {
-	case "stop":
-		return message.FinishReasonEndTurn
-	case "length":
-		return message.FinishReasonMaxTokens
-	case "tool_calls":
-		return message.FinishReasonToolUse
-	default:
-		return message.FinishReasonUnknown
-	}
-}
-
-func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
-	model := o.providerOptions.model(o.providerOptions.modelType)
-	cfg := config.Get()
-
-	modelConfig := cfg.Models[config.SelectedModelTypeLarge]
-	if o.providerOptions.modelType == config.SelectedModelTypeSmall {
-		modelConfig = cfg.Models[config.SelectedModelTypeSmall]
-	}
-
-	reasoningEffort := modelConfig.ReasoningEffort
-
-	params := openai.ChatCompletionNewParams{
-		Model:    openai.ChatModel(model.ID),
-		Messages: messages,
-		Tools:    tools,
-	}
-
-	maxTokens := model.DefaultMaxTokens
-	if modelConfig.MaxTokens > 0 {
-		maxTokens = modelConfig.MaxTokens
-	}
-
-	// Override max tokens if set in provider options
-	if o.providerOptions.maxTokens > 0 {
-		maxTokens = o.providerOptions.maxTokens
-	}
-	if model.CanReason {
-		params.MaxCompletionTokens = openai.Int(maxTokens)
-		switch reasoningEffort {
-		case "low":
-			params.ReasoningEffort = shared.ReasoningEffortLow
-		case "medium":
-			params.ReasoningEffort = shared.ReasoningEffortMedium
-		case "high":
-			params.ReasoningEffort = shared.ReasoningEffortHigh
-		case "minimal":
-			params.ReasoningEffort = shared.ReasoningEffort("minimal")
-		default:
-			params.ReasoningEffort = shared.ReasoningEffort(reasoningEffort)
-		}
-	} else {
-		params.MaxTokens = openai.Int(maxTokens)
-	}
-
-	return params
-}
-
-func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
-	params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
-	attempts := 0
-	for {
-		attempts++
-		openaiResponse, err := o.client.Chat.Completions.New(
-			ctx,
-			params,
-		)
-		// If there is an error we are going to see if we can retry the call
-		if err != nil {
-			retry, after, retryErr := o.shouldRetry(attempts, err)
-			if retryErr != nil {
-				return nil, retryErr
-			}
-			if retry {
-				slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
-				select {
-				case <-ctx.Done():
-					return nil, ctx.Err()
-				case <-time.After(time.Duration(after) * time.Millisecond):
-					continue
-				}
-			}
-			return nil, retryErr
-		}
-
-		if len(openaiResponse.Choices) == 0 {
-			return nil, fmt.Errorf("received empty response from OpenAI API - check endpoint configuration")
-		}
-
-		content := ""
-		if openaiResponse.Choices[0].Message.Content != "" {
-			content = openaiResponse.Choices[0].Message.Content
-		}
-
-		toolCalls := o.toolCalls(*openaiResponse)
-		finishReason := o.finishReason(string(openaiResponse.Choices[0].FinishReason))
-
-		if len(toolCalls) > 0 {
-			finishReason = message.FinishReasonToolUse
-		}
-
-		return &ProviderResponse{
-			Content:      content,
-			ToolCalls:    toolCalls,
-			Usage:        o.usage(*openaiResponse),
-			FinishReason: finishReason,
-		}, nil
-	}
-}
-
-func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
-	params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
-	params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
-		IncludeUsage: openai.Bool(true),
-	}
-
-	attempts := 0
-	eventChan := make(chan ProviderEvent)
-
-	go func() {
-		for {
-			attempts++
-			// Kujtim: fixes an issue with anthropig models on openrouter
-			if len(params.Tools) == 0 {
-				params.Tools = nil
-			}
-			openaiStream := o.client.Chat.Completions.NewStreaming(
-				ctx,
-				params,
-			)
-
-			acc := openai.ChatCompletionAccumulator{}
-			currentContent := ""
-			toolCalls := make([]message.ToolCall, 0)
-			msgToolCalls := make(map[int64]openai.ChatCompletionMessageToolCall)
-			toolMap := make(map[string]openai.ChatCompletionMessageToolCall)
-			toolCallIDMap := make(map[string]string)
-			for openaiStream.Next() {
-				chunk := openaiStream.Current()
-				// Kujtim: this is an issue with openrouter qwen, its sending -1 for the tool index
-				if len(chunk.Choices) != 0 && len(chunk.Choices[0].Delta.ToolCalls) > 0 && chunk.Choices[0].Delta.ToolCalls[0].Index == -1 {
-					chunk.Choices[0].Delta.ToolCalls[0].Index = 0
-				}
-				acc.AddChunk(chunk)
-				for i, choice := range chunk.Choices {
-					reasoning, ok := choice.Delta.JSON.ExtraFields["reasoning"]
-					if ok && reasoning.Raw() != "" {
-						reasoningStr := ""
-						json.Unmarshal([]byte(reasoning.Raw()), &reasoningStr)
-						if reasoningStr != "" {
-							eventChan <- ProviderEvent{
-								Type:     EventThinkingDelta,
-								Thinking: reasoningStr,
-							}
-						}
-					}
-					if choice.Delta.Content != "" {
-						eventChan <- ProviderEvent{
-							Type:    EventContentDelta,
-							Content: choice.Delta.Content,
-						}
-						currentContent += choice.Delta.Content
-					} else if len(choice.Delta.ToolCalls) > 0 {
-						toolCall := choice.Delta.ToolCalls[0]
-						if strings.HasPrefix(toolCall.ID, "functions.") {
-							exID, ok := toolCallIDMap[toolCall.ID]
-							if !ok {
-								newID := uuid.NewString()
-								toolCallIDMap[toolCall.ID] = newID
-								toolCall.ID = newID
-							} else {
-								toolCall.ID = exID
-							}
-						}
-						newToolCall := false
-						if existingToolCall, ok := msgToolCalls[toolCall.Index]; ok { // tool call exists
-							if toolCall.ID != "" && toolCall.ID != existingToolCall.ID {
-								found := false
-								// try to find the tool based on the ID
-								for _, tool := range msgToolCalls {
-									if tool.ID == toolCall.ID {
-										existingToolCall.Function.Arguments += toolCall.Function.Arguments
-										msgToolCalls[toolCall.Index] = existingToolCall
-										toolMap[existingToolCall.ID] = existingToolCall
-										found = true
-									}
-								}
-								if !found {
-									newToolCall = true
-								}
-							} else {
-								existingToolCall.Function.Arguments += toolCall.Function.Arguments
-								msgToolCalls[toolCall.Index] = existingToolCall
-								toolMap[existingToolCall.ID] = existingToolCall
-							}
-						} else {
-							newToolCall = true
-						}
-						if newToolCall { // new tool call
-							if toolCall.ID == "" {
-								toolCall.ID = uuid.NewString()
-							}
-							eventChan <- ProviderEvent{
-								Type: EventToolUseStart,
-								ToolCall: &message.ToolCall{
-									ID:       toolCall.ID,
-									Name:     toolCall.Function.Name,
-									Finished: false,
-								},
-							}
-							msgToolCalls[toolCall.Index] = openai.ChatCompletionMessageToolCall{
-								ID:   toolCall.ID,
-								Type: "function",
-								Function: openai.ChatCompletionMessageToolCallFunction{
-									Name:      toolCall.Function.Name,
-									Arguments: toolCall.Function.Arguments,
-								},
-							}
-							toolMap[toolCall.ID] = msgToolCalls[toolCall.Index]
-						}
-						toolCalls := []openai.ChatCompletionMessageToolCall{}
-						for _, tc := range toolMap {
-							toolCalls = append(toolCalls, tc)
-						}
-						acc.Choices[i].Message.ToolCalls = toolCalls
-					}
-				}
-			}
-
-			err := openaiStream.Err()
-			if err == nil || errors.Is(err, io.EOF) {
-				if len(acc.Choices) == 0 {
-					eventChan <- ProviderEvent{
-						Type:  EventError,
-						Error: fmt.Errorf("received empty streaming response from OpenAI API - check endpoint configuration"),
-					}
-					return
-				}
-
-				resultFinishReason := acc.Choices[0].FinishReason
-				if resultFinishReason == "" {
-					// If the finish reason is empty, we assume it was a successful completion
-					// INFO: this is happening for openrouter for some reason
-					resultFinishReason = "stop"
-				}
-				// Stream completed successfully
-				finishReason := o.finishReason(resultFinishReason)
-				if len(acc.Choices[0].Message.ToolCalls) > 0 {
-					toolCalls = append(toolCalls, o.toolCalls(acc.ChatCompletion)...)
-				}
-				if len(toolCalls) > 0 {
-					finishReason = message.FinishReasonToolUse
-				}
-
-				eventChan <- ProviderEvent{
-					Type: EventComplete,
-					Response: &ProviderResponse{
-						Content:      currentContent,
-						ToolCalls:    toolCalls,
-						Usage:        o.usage(acc.ChatCompletion),
-						FinishReason: finishReason,
-					},
-				}
-				close(eventChan)
-				return
-			}
-
-			// If there is an error we are going to see if we can retry the call
-			retry, after, retryErr := o.shouldRetry(attempts, err)
-			if retryErr != nil {
-				eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
-				close(eventChan)
-				return
-			}
-			if retry {
-				slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
-				select {
-				case <-ctx.Done():
-					// context cancelled
-					if ctx.Err() != nil {
-						eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
-					}
-					close(eventChan)
-					return
-				case <-time.After(time.Duration(after) * time.Millisecond):
-					continue
-				}
-			}
-			eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
-			close(eventChan)
-			return
-		}
-	}()
-
-	return eventChan
-}
-
-func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
-	if attempts > maxRetries {
-		return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
-	}
-	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
-		return false, 0, err
-	}
-	var apiErr *openai.Error
-	retryMs := 0
-	retryAfterValues := []string{}
-	if errors.As(err, &apiErr) {
-		// Check for token expiration (401 Unauthorized)
-		if apiErr.StatusCode == http.StatusUnauthorized {
-			prev := o.providerOptions.apiKey
-			// in case the key comes from a script, we try to re-evaluate it.
-			o.providerOptions.apiKey, err = config.Get().Resolve(o.providerOptions.config.APIKey)
-			if err != nil {
-				return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
-			}
-			// if it didn't change, do not retry.
-			if prev == o.providerOptions.apiKey {
-				return false, 0, err
-			}
-			o.client = createOpenAIClient(o.providerOptions)
-			return true, 0, nil
-		}
-
-		if apiErr.StatusCode == http.StatusTooManyRequests {
-			// Check if this is an insufficient quota error (permanent)
-			if apiErr.Type == "insufficient_quota" || apiErr.Code == "insufficient_quota" {
-				return false, 0, fmt.Errorf("OpenAI quota exceeded: %s. Please check your plan and billing details", apiErr.Message)
-			}
-			// Other 429 errors (rate limiting) can be retried
-		} else if apiErr.StatusCode != http.StatusInternalServerError {
-			return false, 0, err
-		}
-
-		if apiErr.Response != nil {
-			retryAfterValues = apiErr.Response.Header.Values("Retry-After")
-		}
-	}
-
-	if apiErr != nil {
-		slog.Warn("OpenAI API error", "status_code", apiErr.StatusCode, "message", apiErr.Message, "type", apiErr.Type)
-		if len(retryAfterValues) > 0 {
-			slog.Warn("Retry-After header", "values", retryAfterValues)
-		}
-	} else {
-		slog.Error("OpenAI API error", "error", err.Error(), "attempt", attempts, "max_retries", maxRetries)
-	}
-
-	backoffMs := 2000 * (1 << (attempts - 1))
-	jitterMs := int(float64(backoffMs) * 0.2)
-	retryMs = backoffMs + jitterMs
-	if len(retryAfterValues) > 0 {
-		if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
-			retryMs = retryMs * 1000
-		}
-	}
-	return true, int64(retryMs), nil
-}
-
-func (o *openaiClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall {
-	var toolCalls []message.ToolCall
-
-	if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
-		for _, call := range completion.Choices[0].Message.ToolCalls {
-			// accumulator for some reason does this.
-			if call.Function.Name == "" {
-				continue
-			}
-			toolCall := message.ToolCall{
-				ID:       call.ID,
-				Name:     call.Function.Name,
-				Input:    call.Function.Arguments,
-				Type:     "function",
-				Finished: true,
-			}
-			toolCalls = append(toolCalls, toolCall)
-		}
-	}
-
-	return toolCalls
-}
-
-func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
-	cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
-	inputTokens := completion.Usage.PromptTokens - cachedTokens
-
-	return TokenUsage{
-		InputTokens:         inputTokens,
-		OutputTokens:        completion.Usage.CompletionTokens,
-		CacheCreationTokens: 0, // OpenAI doesn't provide this directly
-		CacheReadTokens:     cachedTokens,
-	}
-}
-
-func (o *openaiClient) Model() catwalk.Model {
-	return o.providerOptions.model(o.providerOptions.modelType)
-}
diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go
deleted file mode 100644
index 0dada9d8..00000000
--- a/internal/llm/provider/provider.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package provider
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/message"
-)
-
-type EventType string
-
-const maxRetries = 3
-
-const (
-	EventContentStart   EventType = "content_start"
-	EventToolUseStart   EventType = "tool_use_start"
-	EventToolUseDelta   EventType = "tool_use_delta"
-	EventToolUseStop    EventType = "tool_use_stop"
-	EventContentDelta   EventType = "content_delta"
-	EventThinkingDelta  EventType = "thinking_delta"
-	EventSignatureDelta EventType = "signature_delta"
-	EventContentStop    EventType = "content_stop"
-	EventComplete       EventType = "complete"
-	EventError          EventType = "error"
-	EventWarning        EventType = "warning"
-)
-
-type TokenUsage struct {
-	InputTokens         int64
-	OutputTokens        int64
-	CacheCreationTokens int64
-	CacheReadTokens     int64
-}
-
-type ProviderResponse struct {
-	Content      string
-	ToolCalls    []message.ToolCall
-	Usage        TokenUsage
-	FinishReason message.FinishReason
-}
-
-type ProviderEvent struct {
-	Type EventType
-
-	Content   string
-	Thinking  string
-	Signature string
-	Response  *ProviderResponse
-	ToolCall  *message.ToolCall
-	Error     error
-}
-type Provider interface {
-	SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
-
-	StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
-
-	Model() catwalk.Model
-}
-
-type providerClientOptions struct {
-	baseURL            string
-	config             config.ProviderConfig
-	apiKey             string
-	modelType          config.SelectedModelType
-	model              func(config.SelectedModelType) catwalk.Model
-	disableCache       bool
-	systemMessage      string
-	systemPromptPrefix string
-	maxTokens          int64
-	extraHeaders       map[string]string
-	extraBody          map[string]any
-	extraParams        map[string]string
-}
-
-type ProviderClientOption func(*providerClientOptions)
-
-type ProviderClient interface {
-	send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
-	stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
-
-	Model() catwalk.Model
-}
-
-type baseProvider[C ProviderClient] struct {
-	options providerClientOptions
-	client  C
-}
-
-func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []message.Message) {
-	for _, msg := range messages {
-		// The message has no content
-		if len(msg.Parts) == 0 {
-			continue
-		}
-		cleaned = append(cleaned, msg)
-	}
-	return cleaned
-}
-
-func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
-	messages = p.cleanMessages(messages)
-	return p.client.send(ctx, messages, tools)
-}
-
-func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
-	messages = p.cleanMessages(messages)
-	return p.client.stream(ctx, messages, tools)
-}
-
-func (p *baseProvider[C]) Model() catwalk.Model {
-	return p.client.Model()
-}
-
-func WithModel(model config.SelectedModelType) ProviderClientOption {
-	return func(options *providerClientOptions) {
-		options.modelType = model
-	}
-}
-
-func WithDisableCache(disableCache bool) ProviderClientOption {
-	return func(options *providerClientOptions) {
-		options.disableCache = disableCache
-	}
-}
-
-func WithSystemMessage(systemMessage string) ProviderClientOption {
-	return func(options *providerClientOptions) {
-		options.systemMessage = systemMessage
-	}
-}
-
-func WithMaxTokens(maxTokens int64) ProviderClientOption {
-	return func(options *providerClientOptions) {
-		options.maxTokens = maxTokens
-	}
-}
-
-func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provider, error) {
-	restore := config.PushPopCrushEnv()
-	defer restore()
-	resolvedAPIKey, err := config.Get().Resolve(cfg.APIKey)
-	if err != nil {
-		return nil, fmt.Errorf("failed to resolve API key for provider %s: %w", cfg.ID, err)
-	}
-
-	// Resolve extra headers
-	resolvedExtraHeaders := make(map[string]string)
-	for key, value := range cfg.ExtraHeaders {
-		resolvedValue, err := config.Get().Resolve(value)
-		if err != nil {
-			return nil, fmt.Errorf("failed to resolve extra header %s for provider %s: %w", key, cfg.ID, err)
-		}
-		resolvedExtraHeaders[key] = resolvedValue
-	}
-
-	clientOptions := providerClientOptions{
-		baseURL:            cfg.BaseURL,
-		config:             cfg,
-		apiKey:             resolvedAPIKey,
-		extraHeaders:       resolvedExtraHeaders,
-		extraBody:          cfg.ExtraBody,
-		extraParams:        cfg.ExtraParams,
-		systemPromptPrefix: cfg.SystemPromptPrefix,
-		model: func(tp config.SelectedModelType) catwalk.Model {
-			return *config.Get().GetModelByType(tp)
-		},
-	}
-	for _, o := range opts {
-		o(&clientOptions)
-	}
-	switch cfg.Type {
-	case catwalk.TypeAnthropic:
-		return &baseProvider[AnthropicClient]{
-			options: clientOptions,
-			client:  newAnthropicClient(clientOptions, AnthropicClientTypeNormal),
-		}, nil
-	case catwalk.TypeOpenAI:
-		return &baseProvider[OpenAIClient]{
-			options: clientOptions,
-			client:  newOpenAIClient(clientOptions),
-		}, nil
-	case catwalk.TypeGemini:
-		return &baseProvider[GeminiClient]{
-			options: clientOptions,
-			client:  newGeminiClient(clientOptions),
-		}, nil
-	case catwalk.TypeBedrock:
-		return &baseProvider[BedrockClient]{
-			options: clientOptions,
-			client:  newBedrockClient(clientOptions),
-		}, nil
-	case catwalk.TypeAzure:
-		return &baseProvider[AzureClient]{
-			options: clientOptions,
-			client:  newAzureClient(clientOptions),
-		}, nil
-	case catwalk.TypeVertexAI:
-		return &baseProvider[VertexAIClient]{
-			options: clientOptions,
-			client:  newVertexAIClient(clientOptions),
-		}, nil
-	}
-	return nil, fmt.Errorf("provider not supported: %s", cfg.Type)
-}
diff --git a/internal/llm/provider/vertexai.go b/internal/llm/provider/vertexai.go
deleted file mode 100644
index e7591af7..00000000
--- a/internal/llm/provider/vertexai.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package provider
-
-import (
-	"context"
-	"log/slog"
-	"strings"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/log"
-	"google.golang.org/genai"
-)
-
-type VertexAIClient ProviderClient
-
-func newVertexAIClient(opts providerClientOptions) VertexAIClient {
-	project := opts.extraParams["project"]
-	location := opts.extraParams["location"]
-	cc := &genai.ClientConfig{
-		Project:  project,
-		Location: location,
-		Backend:  genai.BackendVertexAI,
-	}
-	if config.Get().Options.Debug {
-		cc.HTTPClient = log.NewHTTPClient()
-	}
-	client, err := genai.NewClient(context.Background(), cc)
-	if err != nil {
-		slog.Error("Failed to create VertexAI client", "error", err)
-		return nil
-	}
-
-	model := opts.model(opts.modelType)
-	if strings.Contains(model.ID, "anthropic") || strings.Contains(model.ID, "claude") || strings.Contains(model.ID, "sonnet") {
-		return newAnthropicClient(opts, AnthropicClientTypeVertex)
-	}
-	return &geminiClient{
-		providerOptions: opts,
-		client:          client,
-	}
-}
diff --git a/internal/llm/tools/bash.go b/internal/llm/tools/bash.go
deleted file mode 100644
index 7f91ecb7..00000000
--- a/internal/llm/tools/bash.go
+++ /dev/null
@@ -1,395 +0,0 @@
-package tools
-
-import (
-	"bytes"
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"html/template"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/permission"
-	"github.com/charmbracelet/crush/internal/shell"
-)
-
-type BashParams struct {
-	Command string `json:"command"`
-	Timeout int    `json:"timeout"`
-}
-
-type BashPermissionsParams struct {
-	Command string `json:"command"`
-	Timeout int    `json:"timeout"`
-}
-
-type BashResponseMetadata struct {
-	StartTime        int64  `json:"start_time"`
-	EndTime          int64  `json:"end_time"`
-	Output           string `json:"output"`
-	WorkingDirectory string `json:"working_directory"`
-}
-type bashTool struct {
-	permissions permission.Service
-	workingDir  string
-	attribution *config.Attribution
-}
-
-const (
-	BashToolName = "bash"
-
-	DefaultTimeout  = 1 * 60 * 1000  // 1 minutes in milliseconds
-	MaxTimeout      = 10 * 60 * 1000 // 10 minutes in milliseconds
-	MaxOutputLength = 30000
-	BashNoOutput    = "no output"
-)
-
-//go:embed bash.md
-var bashDescription []byte
-
-var bashDescriptionTpl = template.Must(
-	template.New("bashDescription").
-		Parse(string(bashDescription)),
-)
-
-type bashDescriptionData struct {
-	BannedCommands     string
-	MaxOutputLength    int
-	AttributionStep    string
-	AttributionExample string
-	PRAttribution      string
-}
-
-var bannedCommands = []string{
-	// Network/Download tools
-	"alias",
-	"aria2c",
-	"axel",
-	"chrome",
-	"curl",
-	"curlie",
-	"firefox",
-	"http-prompt",
-	"httpie",
-	"links",
-	"lynx",
-	"nc",
-	"safari",
-	"scp",
-	"ssh",
-	"telnet",
-	"w3m",
-	"wget",
-	"xh",
-
-	// System administration
-	"doas",
-	"su",
-	"sudo",
-
-	// Package managers
-	"apk",
-	"apt",
-	"apt-cache",
-	"apt-get",
-	"dnf",
-	"dpkg",
-	"emerge",
-	"home-manager",
-	"makepkg",
-	"opkg",
-	"pacman",
-	"paru",
-	"pkg",
-	"pkg_add",
-	"pkg_delete",
-	"portage",
-	"rpm",
-	"yay",
-	"yum",
-	"zypper",
-
-	// System modification
-	"at",
-	"batch",
-	"chkconfig",
-	"crontab",
-	"fdisk",
-	"mkfs",
-	"mount",
-	"parted",
-	"service",
-	"systemctl",
-	"umount",
-
-	// Network configuration
-	"firewall-cmd",
-	"ifconfig",
-	"ip",
-	"iptables",
-	"netstat",
-	"pfctl",
-	"route",
-	"ufw",
-}
-
-func (b *bashTool) bashDescription() string {
-	bannedCommandsStr := strings.Join(bannedCommands, ", ")
-
-	// Build attribution text based on settings
-	var attributionStep, attributionExample, prAttribution string
-
-	// Default to true if attribution is nil (backward compatibility)
-	generatedWith := b.attribution == nil || b.attribution.GeneratedWith
-	coAuthoredBy := b.attribution == nil || b.attribution.CoAuthoredBy
-
-	// Build PR attribution
-	if generatedWith {
-		prAttribution = "💘 Generated with Crush"
-	}
-
-	if generatedWith || coAuthoredBy {
-		var attributionParts []string
-		if generatedWith {
-			attributionParts = append(attributionParts, "💘 Generated with Crush")
-		}
-		if coAuthoredBy {
-			attributionParts = append(attributionParts, "Co-Authored-By: Crush <crush@charm.land>")
-		}
-
-		if len(attributionParts) > 0 {
-			attributionStep = fmt.Sprintf("4. Create the commit with a message ending with:\n%s", strings.Join(attributionParts, "\n"))
-
-			attributionText := strings.Join(attributionParts, "\n ")
-			attributionExample = fmt.Sprintf(`<example>
-git commit -m "$(cat <<'EOF'
- Commit message here.
-
- %s
- EOF
-)"</example>`, attributionText)
-		}
-	}
-
-	if attributionStep == "" {
-		attributionStep = "4. Create the commit with your commit message."
-		attributionExample = `<example>
-git commit -m "$(cat <<'EOF'
- Commit message here.
- EOF
-)"</example>`
-	}
-
-	var out bytes.Buffer
-	if err := bashDescriptionTpl.Execute(&out, bashDescriptionData{
-		BannedCommands:     bannedCommandsStr,
-		MaxOutputLength:    MaxOutputLength,
-		AttributionStep:    attributionStep,
-		AttributionExample: attributionExample,
-		PRAttribution:      prAttribution,
-	}); err != nil {
-		// this should never happen.
-		panic("failed to execute bash description template: " + err.Error())
-	}
-	return out.String()
-}
-
-func blockFuncs() []shell.BlockFunc {
-	return []shell.BlockFunc{
-		shell.CommandsBlocker(bannedCommands),
-
-		// System package managers
-		shell.ArgumentsBlocker("apk", []string{"add"}, nil),
-		shell.ArgumentsBlocker("apt", []string{"install"}, nil),
-		shell.ArgumentsBlocker("apt-get", []string{"install"}, nil),
-		shell.ArgumentsBlocker("dnf", []string{"install"}, nil),
-		shell.ArgumentsBlocker("pacman", nil, []string{"-S"}),
-		shell.ArgumentsBlocker("pkg", []string{"install"}, nil),
-		shell.ArgumentsBlocker("yum", []string{"install"}, nil),
-		shell.ArgumentsBlocker("zypper", []string{"install"}, nil),
-
-		// Language-specific package managers
-		shell.ArgumentsBlocker("brew", []string{"install"}, nil),
-		shell.ArgumentsBlocker("cargo", []string{"install"}, nil),
-		shell.ArgumentsBlocker("gem", []string{"install"}, nil),
-		shell.ArgumentsBlocker("go", []string{"install"}, nil),
-		shell.ArgumentsBlocker("npm", []string{"install"}, []string{"--global"}),
-		shell.ArgumentsBlocker("npm", []string{"install"}, []string{"-g"}),
-		shell.ArgumentsBlocker("pip", []string{"install"}, []string{"--user"}),
-		shell.ArgumentsBlocker("pip3", []string{"install"}, []string{"--user"}),
-		shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"--global"}),
-		shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"-g"}),
-		shell.ArgumentsBlocker("yarn", []string{"global", "add"}, nil),
-
-		// `go test -exec` can run arbitrary commands
-		shell.ArgumentsBlocker("go", []string{"test"}, []string{"-exec"}),
-	}
-}
-
-func NewBashTool(permission permission.Service, workingDir string, attribution *config.Attribution) BaseTool {
-	// Set up command blocking on the persistent shell
-	persistentShell := shell.GetPersistentShell(workingDir)
-	persistentShell.SetBlockFuncs(blockFuncs())
-
-	return &bashTool{
-		permissions: permission,
-		workingDir:  workingDir,
-		attribution: attribution,
-	}
-}
-
-func (b *bashTool) Name() string {
-	return BashToolName
-}
-
-func (b *bashTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        BashToolName,
-		Description: b.bashDescription(),
-		Parameters: map[string]any{
-			"command": map[string]any{
-				"type":        "string",
-				"description": "The command to execute",
-			},
-			"timeout": map[string]any{
-				"type":        "number",
-				"description": "Optional timeout in milliseconds (max 600000)",
-			},
-		},
-		Required: []string{"command"},
-	}
-}
-
-func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params BashParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse("invalid parameters"), nil
-	}
-
-	if params.Timeout > MaxTimeout {
-		params.Timeout = MaxTimeout
-	} else if params.Timeout <= 0 {
-		params.Timeout = DefaultTimeout
-	}
-
-	if params.Command == "" {
-		return NewTextErrorResponse("missing command"), nil
-	}
-
-	isSafeReadOnly := false
-	cmdLower := strings.ToLower(params.Command)
-
-	for _, safe := range safeCommands {
-		if strings.HasPrefix(cmdLower, safe) {
-			if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
-				isSafeReadOnly = true
-				break
-			}
-		}
-	}
-
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for executing shell command")
-	}
-	if !isSafeReadOnly {
-		shell := shell.GetPersistentShell(b.workingDir)
-		p := b.permissions.Request(
-			permission.CreatePermissionRequest{
-				SessionID:   sessionID,
-				Path:        shell.GetWorkingDir(),
-				ToolCallID:  call.ID,
-				ToolName:    BashToolName,
-				Action:      "execute",
-				Description: fmt.Sprintf("Execute command: %s", params.Command),
-				Params: BashPermissionsParams{
-					Command: params.Command,
-				},
-			},
-		)
-		if !p {
-			return ToolResponse{}, permission.ErrorPermissionDenied
-		}
-	}
-	startTime := time.Now()
-	if params.Timeout > 0 {
-		var cancel context.CancelFunc
-		ctx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Millisecond)
-		defer cancel()
-	}
-
-	persistentShell := shell.GetPersistentShell(b.workingDir)
-	stdout, stderr, err := persistentShell.Exec(ctx, params.Command)
-
-	// Get the current working directory after command execution
-	currentWorkingDir := persistentShell.GetWorkingDir()
-	interrupted := shell.IsInterrupt(err)
-	exitCode := shell.ExitCode(err)
-	if exitCode == 0 && !interrupted && err != nil {
-		return ToolResponse{}, fmt.Errorf("error executing command: %w", err)
-	}
-
-	stdout = truncateOutput(stdout)
-	stderr = truncateOutput(stderr)
-
-	errorMessage := stderr
-	if errorMessage == "" && err != nil {
-		errorMessage = err.Error()
-	}
-
-	if interrupted {
-		if errorMessage != "" {
-			errorMessage += "\n"
-		}
-		errorMessage += "Command was aborted before completion"
-	} else if exitCode != 0 {
-		if errorMessage != "" {
-			errorMessage += "\n"
-		}
-		errorMessage += fmt.Sprintf("Exit code %d", exitCode)
-	}
-
-	hasBothOutputs := stdout != "" && stderr != ""
-
-	if hasBothOutputs {
-		stdout += "\n"
-	}
-
-	if errorMessage != "" {
-		stdout += "\n" + errorMessage
-	}
-
-	metadata := BashResponseMetadata{
-		StartTime:        startTime.UnixMilli(),
-		EndTime:          time.Now().UnixMilli(),
-		Output:           stdout,
-		WorkingDirectory: currentWorkingDir,
-	}
-	if stdout == "" {
-		return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
-	}
-	stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", currentWorkingDir)
-	return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
-}
-
-func truncateOutput(content string) string {
-	if len(content) <= MaxOutputLength {
-		return content
-	}
-
-	halfLength := MaxOutputLength / 2
-	start := content[:halfLength]
-	end := content[len(content)-halfLength:]
-
-	truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
-	return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
-}
-
-func countLines(s string) int {
-	if s == "" {
-		return 0
-	}
-	return len(strings.Split(s, "\n"))
-}
diff --git a/internal/llm/tools/bash.md b/internal/llm/tools/bash.md
deleted file mode 100644
index 464b2749..00000000
--- a/internal/llm/tools/bash.md
+++ /dev/null
@@ -1,161 +0,0 @@
-Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
-
-CROSS-PLATFORM SHELL SUPPORT:
-
-- This tool uses a shell interpreter (mvdan/sh) that mimics the Bash language,
-  so you should use Bash syntax in all platforms, including Windows.
-  The most common shell builtins and core utils are available in Windows as
-  well.
-- Make sure to use forward slashes (/) as path separators in commands, even on
-  Windows. Example: "ls C:/foo/bar" instead of "ls C:\foo\bar".
-
-Before executing the command, please follow these steps:
-
-1. Directory Verification:
-
-- If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location
-- For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
-
-2. Security Check:
-
-- For security and to limit the threat of a prompt injection attack, some commands are limited or banned. If you use a disallowed command, you will receive an error message explaining the restriction. Explain the error to the User.
-- Verify that the command is not one of the banned commands: {{ .BannedCommands }}.
-
-3. Command Execution:
-
-- After ensuring proper quoting, execute the command.
-- Capture the output of the command.
-
-4. Output Processing:
-
-- If the output exceeds {{ .MaxOutputLength }} characters, output will be truncated before being returned to you.
-- Prepare the output for display to the user.
-
-5. Return Result:
-
-- Provide the processed output of the command.
-- If any errors occurred during execution, include those in the output.
-- The result will also have metadata like the cwd (current working directory) at the end, included with <cwd></cwd> tags.
-
-Usage notes:
-
-- The command argument is required.
-- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.
-- VERY IMPORTANT: You MUST avoid using search commands like 'find' and 'grep'. Instead use Grep, Glob, or Agent tools to search. You MUST avoid read tools like 'cat', 'head', 'tail', and 'ls', and use FileRead and LS tools to read files.
-- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
-- IMPORTANT: All commands share the same shell session. Shell state (environment variables, virtual environments, current directory, etc.) persist between commands. For example, if you set an environment variable as part of a command, the environment variable will persist for subsequent commands.
-- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of 'cd'. You may use 'cd' if the User explicitly requests it.
-  <good-example>
-  pytest /foo/bar/tests
-  </good-example>
-  <bad-example>
-  cd /foo/bar && pytest tests
-  </bad-example>
-
-# Committing changes with git
-
-When the user asks you to create a new git commit, follow these steps carefully:
-
-1. Start with a single message that contains exactly three tool_use blocks that do the following (it is VERY IMPORTANT that you send these tool_use blocks in a single message, otherwise it will feel slow to the user!):
-
-- Run a git status command to see all untracked files.
-- Run a git diff command to see both staged and unstaged changes that will be committed.
-- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
-
-2. Use the git context at the start of this conversation to determine which files are relevant to your commit. Add relevant untracked files to the staging area. Do not commit files that were already modified at the start of this conversation, if they are not relevant to your commit.
-
-3. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in <commit_analysis> tags:
-
-<commit_analysis>
-
-- List the files that have been changed or added
-- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
-- Brainstorm the purpose or motivation behind these changes
-- Do not use tools to explore code, beyond what is available in the git context
-- Assess the impact of these changes on the overall project
-- Check for any sensitive information that shouldn't be committed
-- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
-- Ensure your language is clear, concise, and to the point
-- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
-- Ensure the message is not generic (avoid words like "Update" or "Fix" without context)
-- Review the draft message to ensure it accurately reflects the changes and their purpose
-  </commit_analysis>
-
-{{ .AttributionStep }}
-
-- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
-  {{ .AttributionExample }}
-
-5. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
-
-6. Finally, run git status to make sure the commit succeeded.
-
-Important notes:
-
-- When possible, combine the "git add" and "git commit" commands into a single "git commit -am" command, to speed things up
-- However, be careful not to stage files (e.g. with 'git add .') for commits that aren't part of the change, they may have untracked files they want to keep around, but not commit.
-- NEVER update the git config
-- DO NOT push to the remote repository
-- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
-- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
-- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
-- Return an empty response - the user will see the git output directly
-
-# Creating pull requests
-
-Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
-
-IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
-
-1. Understand the current state of the branch. Remember to send a single message that contains multiple tool_use blocks (it is VERY IMPORTANT that you do this in a single message, otherwise it will feel slow to the user!):
-
-- Run a git status command to see all untracked files.
-- Run a git diff command to see both staged and unstaged changes that will be committed.
-- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
-- Run a git log command and 'git diff main...HEAD' to understand the full commit history for the current branch (from the time it diverged from the 'main' branch.)
-
-2. Create new branch if needed
-
-3. Commit changes if needed
-
-4. Push to remote with -u flag if needed
-
-5. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (not just the latest commit, but all commits that will be included in the pull request!), and draft a pull request summary. Wrap your analysis process in <pr_analysis> tags:
-
-<pr_analysis>
-
-- List the commits since diverging from the main branch
-- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
-- Brainstorm the purpose or motivation behind these changes
-- Assess the impact of these changes on the overall project
-- Do not use tools to explore code, beyond what is available in the git context
-- Check for any sensitive information that shouldn't be committed
-- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what"
-- Ensure the summary accurately reflects all changes since diverging from the main branch
-- Ensure your language is clear, concise, and to the point
-- Ensure the summary accurately reflects the changes and their purpose (ie. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
-- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context)
-- Review the draft summary to ensure it accurately reflects the changes and their purpose
-  </pr_analysis>
-
-6. Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
-   <example>
-   gh pr create --title "the pr title" --body "$(cat <<'EOF'
-
-## Summary
-
-<1-3 bullet points>
-
-## Test plan
-
-[Checklist of TODOs for testing the pull request...]
-
-{{ .PRAttribution }}
-EOF
-)"
-</example>
-
-Important:
-
-- Return an empty response - the user will see the gh output directly
-- Never update git config
diff --git a/internal/llm/tools/diagnostics.md b/internal/llm/tools/diagnostics.md
deleted file mode 100644
index 3f00d660..00000000
--- a/internal/llm/tools/diagnostics.md
+++ /dev/null
@@ -1,21 +0,0 @@
-Get diagnostics for a file and/or project.
-WHEN TO USE THIS TOOL:
-
-- Use when you need to check for errors or warnings in your code
-- Helpful for debugging and ensuring code quality
-- Good for getting a quick overview of issues in a file or project
-  HOW TO USE:
-- Provide a path to a file to get diagnostics for that file
-- Leave the path empty to get diagnostics for the entire project
-- Results are displayed in a structured format with severity levels
-  FEATURES:
-- Displays errors, warnings, and hints
-- Groups diagnostics by severity
-- Provides detailed information about each diagnostic
-  LIMITATIONS:
-- Results are limited to the diagnostics provided by the LSP clients
-- May not cover all possible issues in the code
-- Does not provide suggestions for fixing issues
-  TIPS:
-- Use in conjunction with other tools for a comprehensive code review
-- Combine with the LSP client for real-time diagnostics
diff --git a/internal/llm/tools/download.go b/internal/llm/tools/download.go
deleted file mode 100644
index 63092cf2..00000000
--- a/internal/llm/tools/download.go
+++ /dev/null
@@ -1,196 +0,0 @@
-package tools
-
-import (
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/permission"
-)
-
-type DownloadParams struct {
-	URL      string `json:"url"`
-	FilePath string `json:"file_path"`
-	Timeout  int    `json:"timeout,omitempty"`
-}
-
-type DownloadPermissionsParams struct {
-	URL      string `json:"url"`
-	FilePath string `json:"file_path"`
-	Timeout  int    `json:"timeout,omitempty"`
-}
-
-type downloadTool struct {
-	client      *http.Client
-	permissions permission.Service
-	workingDir  string
-}
-
-const DownloadToolName = "download"
-
-//go:embed download.md
-var downloadDescription []byte
-
-func NewDownloadTool(permissions permission.Service, workingDir string) BaseTool {
-	return &downloadTool{
-		client: &http.Client{
-			Timeout: 5 * time.Minute, // Default 5 minute timeout for downloads
-			Transport: &http.Transport{
-				MaxIdleConns:        100,
-				MaxIdleConnsPerHost: 10,
-				IdleConnTimeout:     90 * time.Second,
-			},
-		},
-		permissions: permissions,
-		workingDir:  workingDir,
-	}
-}
-
-func (t *downloadTool) Name() string {
-	return DownloadToolName
-}
-
-func (t *downloadTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        DownloadToolName,
-		Description: string(downloadDescription),
-		Parameters: map[string]any{
-			"url": map[string]any{
-				"type":        "string",
-				"description": "The URL to download from",
-			},
-			"file_path": map[string]any{
-				"type":        "string",
-				"description": "The local file path where the downloaded content should be saved",
-			},
-			"timeout": map[string]any{
-				"type":        "number",
-				"description": "Optional timeout in seconds (max 600)",
-			},
-		},
-		Required: []string{"url", "file_path"},
-	}
-}
-
-func (t *downloadTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params DownloadParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse("Failed to parse download parameters: " + err.Error()), nil
-	}
-
-	if params.URL == "" {
-		return NewTextErrorResponse("URL parameter is required"), nil
-	}
-
-	if params.FilePath == "" {
-		return NewTextErrorResponse("file_path parameter is required"), nil
-	}
-
-	if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
-		return NewTextErrorResponse("URL must start with http:// or https://"), nil
-	}
-
-	// Convert relative path to absolute path
-	var filePath string
-	if filepath.IsAbs(params.FilePath) {
-		filePath = params.FilePath
-	} else {
-		filePath = filepath.Join(t.workingDir, params.FilePath)
-	}
-
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for downloading files")
-	}
-
-	p := t.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			Path:        filePath,
-			ToolName:    DownloadToolName,
-			Action:      "download",
-			Description: fmt.Sprintf("Download file from URL: %s to %s", params.URL, filePath),
-			Params:      DownloadPermissionsParams(params),
-		},
-	)
-
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	// Handle timeout with context
-	requestCtx := ctx
-	if params.Timeout > 0 {
-		maxTimeout := 600 // 10 minutes
-		if params.Timeout > maxTimeout {
-			params.Timeout = maxTimeout
-		}
-		var cancel context.CancelFunc
-		requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
-		defer cancel()
-	}
-
-	req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
-	}
-
-	req.Header.Set("User-Agent", "crush/1.0")
-
-	resp, err := t.client.Do(req)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to download from URL: %w", err)
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
-	}
-
-	// Check content length if available
-	maxSize := int64(100 * 1024 * 1024) // 100MB
-	if resp.ContentLength > maxSize {
-		return NewTextErrorResponse(fmt.Sprintf("File too large: %d bytes (max %d bytes)", resp.ContentLength, maxSize)), nil
-	}
-
-	// Create parent directories if they don't exist
-	if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
-	}
-
-	// Create the output file
-	outFile, err := os.Create(filePath)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create output file: %w", err)
-	}
-	defer outFile.Close()
-
-	// Copy data with size limit
-	limitedReader := io.LimitReader(resp.Body, maxSize)
-	bytesWritten, err := io.Copy(outFile, limitedReader)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
-	}
-
-	// Check if we hit the size limit
-	if bytesWritten == maxSize {
-		// Clean up the file since it might be incomplete
-		os.Remove(filePath)
-		return NewTextErrorResponse(fmt.Sprintf("File too large: exceeded %d bytes limit", maxSize)), nil
-	}
-
-	contentType := resp.Header.Get("Content-Type")
-	responseMsg := fmt.Sprintf("Successfully downloaded %d bytes to %s", bytesWritten, filePath)
-	if contentType != "" {
-		responseMsg += fmt.Sprintf(" (Content-Type: %s)", contentType)
-	}
-
-	return NewTextResponse(responseMsg), nil
-}
diff --git a/internal/llm/tools/download.md b/internal/llm/tools/download.md
deleted file mode 100644
index 4a951610..00000000
--- a/internal/llm/tools/download.md
+++ /dev/null
@@ -1,34 +0,0 @@
-Downloads binary data from a URL and saves it to a local file.
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to download files, images, or other binary data from URLs
-- Helpful for downloading assets, documents, or any file type
-- Useful for saving remote content locally for processing or storage
-
-HOW TO USE:
-
-- Provide the URL to download from
-- Specify the local file path where the content should be saved
-- Optionally set a timeout for the request
-
-FEATURES:
-
-- Downloads any file type (binary or text)
-- Automatically creates parent directories if they don't exist
-- Handles large files efficiently with streaming
-- Sets reasonable timeouts to prevent hanging
-- Validates input parameters before making requests
-
-LIMITATIONS:
-
-- Maximum file size is 100MB
-- Only supports HTTP and HTTPS protocols
-- Cannot handle authentication or cookies
-- Some websites may block automated requests
-- Will overwrite existing files without warning
-
-TIPS:
-
-- Use absolute paths or paths relative to the working directory
-- Set appropriate timeouts for large files or slow connections
diff --git a/internal/llm/tools/edit.go b/internal/llm/tools/edit.go
deleted file mode 100644
index ae4322e0..00000000
--- a/internal/llm/tools/edit.go
+++ /dev/null
@@ -1,486 +0,0 @@
-package tools
-
-import (
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"log/slog"
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/diff"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/history"
-
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/permission"
-)
-
-type EditParams struct {
-	FilePath   string `json:"file_path"`
-	OldString  string `json:"old_string"`
-	NewString  string `json:"new_string"`
-	ReplaceAll bool   `json:"replace_all,omitempty"`
-}
-
-type EditPermissionsParams struct {
-	FilePath   string `json:"file_path"`
-	OldContent string `json:"old_content,omitempty"`
-	NewContent string `json:"new_content,omitempty"`
-}
-
-type EditResponseMetadata struct {
-	Additions  int    `json:"additions"`
-	Removals   int    `json:"removals"`
-	OldContent string `json:"old_content,omitempty"`
-	NewContent string `json:"new_content,omitempty"`
-}
-
-type editTool struct {
-	lspClients  *csync.Map[string, *lsp.Client]
-	permissions permission.Service
-	files       history.Service
-	workingDir  string
-}
-
-const EditToolName = "edit"
-
-//go:embed edit.md
-var editDescription []byte
-
-func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) BaseTool {
-	return &editTool{
-		lspClients:  lspClients,
-		permissions: permissions,
-		files:       files,
-		workingDir:  workingDir,
-	}
-}
-
-func (e *editTool) Name() string {
-	return EditToolName
-}
-
-func (e *editTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        EditToolName,
-		Description: string(editDescription),
-		Parameters: map[string]any{
-			"file_path": map[string]any{
-				"type":        "string",
-				"description": "The absolute path to the file to modify",
-			},
-			"old_string": map[string]any{
-				"type":        "string",
-				"description": "The text to replace",
-			},
-			"new_string": map[string]any{
-				"type":        "string",
-				"description": "The text to replace it with",
-			},
-			"replace_all": map[string]any{
-				"type":        "boolean",
-				"description": "Replace all occurrences of old_string (default false)",
-			},
-		},
-		Required: []string{"file_path", "old_string", "new_string"},
-	}
-}
-
-func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params EditParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse("invalid parameters"), nil
-	}
-
-	if params.FilePath == "" {
-		return NewTextErrorResponse("file_path is required"), nil
-	}
-
-	if !filepath.IsAbs(params.FilePath) {
-		params.FilePath = filepath.Join(e.workingDir, params.FilePath)
-	}
-
-	var response ToolResponse
-	var err error
-
-	if params.OldString == "" {
-		response, err = e.createNewFile(ctx, params.FilePath, params.NewString, call)
-		if err != nil {
-			return response, err
-		}
-	}
-
-	if params.NewString == "" {
-		response, err = e.deleteContent(ctx, params.FilePath, params.OldString, params.ReplaceAll, call)
-		if err != nil {
-			return response, err
-		}
-	}
-
-	response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
-	if err != nil {
-		return response, err
-	}
-	if response.IsError {
-		// Return early if there was an error during content replacement
-		// This prevents unnecessary LSP diagnostics processing
-		return response, nil
-	}
-
-	notifyLSPs(ctx, e.lspClients, params.FilePath)
-
-	text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
-	text += getDiagnostics(params.FilePath, e.lspClients)
-	response.Content = text
-	return response, nil
-}
-
-func (e *editTool) createNewFile(ctx context.Context, filePath, content string, call ToolCall) (ToolResponse, error) {
-	fileInfo, err := os.Stat(filePath)
-	if err == nil {
-		if fileInfo.IsDir() {
-			return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
-		}
-		return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
-	} else if !os.IsNotExist(err) {
-		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
-	}
-
-	dir := filepath.Dir(filePath)
-	if err = os.MkdirAll(dir, 0o755); err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
-	}
-
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
-	}
-
-	_, additions, removals := diff.GenerateDiff(
-		"",
-		content,
-		strings.TrimPrefix(filePath, e.workingDir),
-	)
-	p := e.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			Path:        fsext.PathOrPrefix(filePath, e.workingDir),
-			ToolCallID:  call.ID,
-			ToolName:    EditToolName,
-			Action:      "write",
-			Description: fmt.Sprintf("Create file %s", filePath),
-			Params: EditPermissionsParams{
-				FilePath:   filePath,
-				OldContent: "",
-				NewContent: content,
-			},
-		},
-	)
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	err = os.WriteFile(filePath, []byte(content), 0o644)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
-	}
-
-	// File can't be in the history so we create a new file history
-	_, err = e.files.Create(ctx, sessionID, filePath, "")
-	if err != nil {
-		// Log error but don't fail the operation
-		return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
-	}
-
-	// Add the new content to the file history
-	_, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
-	if err != nil {
-		// Log error but don't fail the operation
-		slog.Debug("Error creating file history version", "error", err)
-	}
-
-	recordFileWrite(filePath)
-	recordFileRead(filePath)
-
-	return WithResponseMetadata(
-		NewTextResponse("File created: "+filePath),
-		EditResponseMetadata{
-			OldContent: "",
-			NewContent: content,
-			Additions:  additions,
-			Removals:   removals,
-		},
-	), nil
-}
-
-func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
-	fileInfo, err := os.Stat(filePath)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
-		}
-		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
-	}
-
-	if fileInfo.IsDir() {
-		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
-	}
-
-	if getLastReadTime(filePath).IsZero() {
-		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
-	}
-
-	modTime := fileInfo.ModTime()
-	lastRead := getLastReadTime(filePath)
-	if modTime.After(lastRead) {
-		return NewTextErrorResponse(
-			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
-				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
-			)), nil
-	}
-
-	content, err := os.ReadFile(filePath)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
-	}
-
-	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
-
-	var newContent string
-	var deletionCount int
-
-	if replaceAll {
-		newContent = strings.ReplaceAll(oldContent, oldString, "")
-		deletionCount = strings.Count(oldContent, oldString)
-		if deletionCount == 0 {
-			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
-		}
-	} else {
-		index := strings.Index(oldContent, oldString)
-		if index == -1 {
-			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
-		}
-
-		lastIndex := strings.LastIndex(oldContent, oldString)
-		if index != lastIndex {
-			return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
-		}
-
-		newContent = oldContent[:index] + oldContent[index+len(oldString):]
-		deletionCount = 1
-	}
-
-	sessionID, messageID := GetContextValues(ctx)
-
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
-	}
-
-	_, additions, removals := diff.GenerateDiff(
-		oldContent,
-		newContent,
-		strings.TrimPrefix(filePath, e.workingDir),
-	)
-
-	p := e.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			Path:        fsext.PathOrPrefix(filePath, e.workingDir),
-			ToolCallID:  call.ID,
-			ToolName:    EditToolName,
-			Action:      "write",
-			Description: fmt.Sprintf("Delete content from file %s", filePath),
-			Params: EditPermissionsParams{
-				FilePath:   filePath,
-				OldContent: oldContent,
-				NewContent: newContent,
-			},
-		},
-	)
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	if isCrlf {
-		newContent, _ = fsext.ToWindowsLineEndings(newContent)
-	}
-
-	err = os.WriteFile(filePath, []byte(newContent), 0o644)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
-	}
-
-	// Check if file exists in history
-	file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
-	if err != nil {
-		_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
-		if err != nil {
-			// Log error but don't fail the operation
-			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
-		}
-	}
-	if file.Content != oldContent {
-		// User Manually changed the content store an intermediate version
-		_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
-		if err != nil {
-			slog.Debug("Error creating file history version", "error", err)
-		}
-	}
-	// Store the new version
-	_, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
-	if err != nil {
-		slog.Debug("Error creating file history version", "error", err)
-	}
-
-	recordFileWrite(filePath)
-	recordFileRead(filePath)
-
-	return WithResponseMetadata(
-		NewTextResponse("Content deleted from file: "+filePath),
-		EditResponseMetadata{
-			OldContent: oldContent,
-			NewContent: newContent,
-			Additions:  additions,
-			Removals:   removals,
-		},
-	), nil
-}
-
-func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
-	fileInfo, err := os.Stat(filePath)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
-		}
-		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
-	}
-
-	if fileInfo.IsDir() {
-		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
-	}
-
-	if getLastReadTime(filePath).IsZero() {
-		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
-	}
-
-	modTime := fileInfo.ModTime()
-	lastRead := getLastReadTime(filePath)
-	if modTime.After(lastRead) {
-		return NewTextErrorResponse(
-			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
-				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
-			)), nil
-	}
-
-	content, err := os.ReadFile(filePath)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
-	}
-
-	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
-
-	var newContent string
-	var replacementCount int
-
-	if replaceAll {
-		newContent = strings.ReplaceAll(oldContent, oldString, newString)
-		replacementCount = strings.Count(oldContent, oldString)
-		if replacementCount == 0 {
-			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
-		}
-	} else {
-		index := strings.Index(oldContent, oldString)
-		if index == -1 {
-			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
-		}
-
-		lastIndex := strings.LastIndex(oldContent, oldString)
-		if index != lastIndex {
-			return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
-		}
-
-		newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
-		replacementCount = 1
-	}
-
-	if oldContent == newContent {
-		return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
-	}
-	sessionID, messageID := GetContextValues(ctx)
-
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
-	}
-	_, additions, removals := diff.GenerateDiff(
-		oldContent,
-		newContent,
-		strings.TrimPrefix(filePath, e.workingDir),
-	)
-
-	p := e.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			Path:        fsext.PathOrPrefix(filePath, e.workingDir),
-			ToolCallID:  call.ID,
-			ToolName:    EditToolName,
-			Action:      "write",
-			Description: fmt.Sprintf("Replace content in file %s", filePath),
-			Params: EditPermissionsParams{
-				FilePath:   filePath,
-				OldContent: oldContent,
-				NewContent: newContent,
-			},
-		},
-	)
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	if isCrlf {
-		newContent, _ = fsext.ToWindowsLineEndings(newContent)
-	}
-
-	err = os.WriteFile(filePath, []byte(newContent), 0o644)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
-	}
-
-	// Check if file exists in history
-	file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
-	if err != nil {
-		_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
-		if err != nil {
-			// Log error but don't fail the operation
-			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
-		}
-	}
-	if file.Content != oldContent {
-		// User Manually changed the content store an intermediate version
-		_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
-		if err != nil {
-			slog.Debug("Error creating file history version", "error", err)
-		}
-	}
-	// Store the new version
-	_, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
-	if err != nil {
-		slog.Debug("Error creating file history version", "error", err)
-	}
-
-	recordFileWrite(filePath)
-	recordFileRead(filePath)
-
-	return WithResponseMetadata(
-		NewTextResponse("Content replaced in file: "+filePath),
-		EditResponseMetadata{
-			OldContent: oldContent,
-			NewContent: newContent,
-			Additions:  additions,
-			Removals:   removals,
-		}), nil
-}
diff --git a/internal/llm/tools/edit.md b/internal/llm/tools/edit.md
deleted file mode 100644
index 2f9ec738..00000000
--- a/internal/llm/tools/edit.md
+++ /dev/null
@@ -1,60 +0,0 @@
-Edits files by replacing text, creating new files, or deleting content. For moving or renaming files, use the Bash tool with the 'mv' command instead. For larger file edits, use the FileWrite tool to overwrite files.
-
-Before using this tool:
-
-1. Use the FileRead tool to understand the file's contents and context
-
-2. Verify the directory path is correct (only applicable when creating new files):
-   - Use the LS tool to verify the parent directory exists and is the correct location
-
-To make a file edit, provide the following:
-
-1. file_path: The absolute path to the file to modify (must be absolute, not relative)
-2. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
-3. new_string: The edited text to replace the old_string
-4. replace_all: Replace all occurrences of old_string (default false)
-
-Special cases:
-
-- To create a new file: provide file_path and new_string, leave old_string empty
-- To delete content: provide file_path and old_string, leave new_string empty
-
-The tool will replace ONE occurrence of old_string with new_string in the specified file by default. Set replace_all to true to replace all occurrences.
-
-CRITICAL REQUIREMENTS FOR USING THIS TOOL:
-
-1. UNIQUENESS: When replace_all is false (default), the old_string MUST uniquely identify the specific instance you want to change. This means:
-   - Include AT LEAST 3-5 lines of context BEFORE the change point
-   - Include AT LEAST 3-5 lines of context AFTER the change point
-   - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
-
-2. SINGLE INSTANCE: When replace_all is false, this tool can only change ONE instance at a time. If you need to change multiple instances:
-   - Set replace_all to true to replace all occurrences at once
-   - Or make separate calls to this tool for each instance
-   - Each call must uniquely identify its specific instance using extensive context
-
-3. VERIFICATION: Before using this tool:
-   - Check how many instances of the target text exist in the file
-   - If multiple instances exist and replace_all is false, gather enough context to uniquely identify each one
-   - Plan separate tool calls for each instance or use replace_all
-
-WARNING: If you do not follow these requirements:
-
-- The tool will fail if old_string matches multiple locations and replace_all is false
-- The tool will fail if old_string doesn't match exactly (including whitespace)
-- You may change the wrong instance if you don't include enough context
-
-When making edits:
-
-- Ensure the edit results in idiomatic, correct code
-- Do not leave the code in a broken state
-- Always use absolute file paths (starting with /)
-
-WINDOWS NOTES:
-
-- File paths should use forward slashes (/) for cross-platform compatibility
-- On Windows, absolute paths start with drive letters (C:/) but forward slashes work throughout
-- File permissions are handled automatically by the Go runtime
-- Always assumes \n for line endings. The tool will handle \r\n conversion automatically if needed.
-
-Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.
diff --git a/internal/llm/tools/fetch.go b/internal/llm/tools/fetch.go
deleted file mode 100644
index e9c57324..00000000
--- a/internal/llm/tools/fetch.go
+++ /dev/null
@@ -1,236 +0,0 @@
-package tools
-
-import (
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"strings"
-	"time"
-	"unicode/utf8"
-
-	md "github.com/JohannesKaufmann/html-to-markdown"
-	"github.com/PuerkitoBio/goquery"
-	"github.com/charmbracelet/crush/internal/permission"
-)
-
-type FetchParams struct {
-	URL     string `json:"url"`
-	Format  string `json:"format"`
-	Timeout int    `json:"timeout,omitempty"`
-}
-
-type FetchPermissionsParams struct {
-	URL     string `json:"url"`
-	Format  string `json:"format"`
-	Timeout int    `json:"timeout,omitempty"`
-}
-
-type fetchTool struct {
-	client      *http.Client
-	permissions permission.Service
-	workingDir  string
-}
-
-const FetchToolName = "fetch"
-
-//go:embed fetch.md
-var fetchDescription []byte
-
-func NewFetchTool(permissions permission.Service, workingDir string) BaseTool {
-	return &fetchTool{
-		client: &http.Client{
-			Timeout: 30 * time.Second,
-			Transport: &http.Transport{
-				MaxIdleConns:        100,
-				MaxIdleConnsPerHost: 10,
-				IdleConnTimeout:     90 * time.Second,
-			},
-		},
-		permissions: permissions,
-		workingDir:  workingDir,
-	}
-}
-
-func (t *fetchTool) Name() string {
-	return FetchToolName
-}
-
-func (t *fetchTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        FetchToolName,
-		Description: string(fetchDescription),
-		Parameters: map[string]any{
-			"url": map[string]any{
-				"type":        "string",
-				"description": "The URL to fetch content from",
-			},
-			"format": map[string]any{
-				"type":        "string",
-				"description": "The format to return the content in (text, markdown, or html)",
-				"enum":        []string{"text", "markdown", "html"},
-			},
-			"timeout": map[string]any{
-				"type":        "number",
-				"description": "Optional timeout in seconds (max 120)",
-			},
-		},
-		Required: []string{"url", "format"},
-	}
-}
-
-func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params FetchParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse("Failed to parse fetch parameters: " + err.Error()), nil
-	}
-
-	if params.URL == "" {
-		return NewTextErrorResponse("URL parameter is required"), nil
-	}
-
-	format := strings.ToLower(params.Format)
-	if format != "text" && format != "markdown" && format != "html" {
-		return NewTextErrorResponse("Format must be one of: text, markdown, html"), nil
-	}
-
-	if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
-		return NewTextErrorResponse("URL must start with http:// or https://"), nil
-	}
-
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
-	}
-
-	p := t.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			Path:        t.workingDir,
-			ToolCallID:  call.ID,
-			ToolName:    FetchToolName,
-			Action:      "fetch",
-			Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
-			Params:      FetchPermissionsParams(params),
-		},
-	)
-
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	// Handle timeout with context
-	requestCtx := ctx
-	if params.Timeout > 0 {
-		maxTimeout := 120 // 2 minutes
-		if params.Timeout > maxTimeout {
-			params.Timeout = maxTimeout
-		}
-		var cancel context.CancelFunc
-		requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
-		defer cancel()
-	}
-
-	req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
-	}
-
-	req.Header.Set("User-Agent", "crush/1.0")
-
-	resp, err := t.client.Do(req)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
-	}
-
-	maxSize := int64(5 * 1024 * 1024) // 5MB
-	body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
-	if err != nil {
-		return NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
-	}
-
-	content := string(body)
-
-	isValidUt8 := utf8.ValidString(content)
-	if !isValidUt8 {
-		return NewTextErrorResponse("Response content is not valid UTF-8"), nil
-	}
-	contentType := resp.Header.Get("Content-Type")
-
-	switch format {
-	case "text":
-		if strings.Contains(contentType, "text/html") {
-			text, err := extractTextFromHTML(content)
-			if err != nil {
-				return NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil
-			}
-			content = text
-		}
-
-	case "markdown":
-		if strings.Contains(contentType, "text/html") {
-			markdown, err := convertHTMLToMarkdown(content)
-			if err != nil {
-				return NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil
-			}
-			content = markdown
-		}
-
-		content = "```\n" + content + "\n```"
-
-	case "html":
-		// return only the body of the HTML document
-		if strings.Contains(contentType, "text/html") {
-			doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
-			if err != nil {
-				return NewTextErrorResponse("Failed to parse HTML: " + err.Error()), nil
-			}
-			body, err := doc.Find("body").Html()
-			if err != nil {
-				return NewTextErrorResponse("Failed to extract body from HTML: " + err.Error()), nil
-			}
-			if body == "" {
-				return NewTextErrorResponse("No body content found in HTML"), nil
-			}
-			content = "<html>\n<body>\n" + body + "\n</body>\n</html>"
-		}
-	}
-	// calculate byte size of content
-	contentSize := int64(len(content))
-	if contentSize > MaxReadSize {
-		content = content[:MaxReadSize]
-		content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxReadSize)
-	}
-
-	return NewTextResponse(content), nil
-}
-
-func extractTextFromHTML(html string) (string, error) {
-	doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
-	if err != nil {
-		return "", err
-	}
-
-	text := doc.Find("body").Text()
-	text = strings.Join(strings.Fields(text), " ")
-
-	return text, nil
-}
-
-func convertHTMLToMarkdown(html string) (string, error) {
-	converter := md.NewConverter("", true, nil)
-
-	markdown, err := converter.ConvertString(html)
-	if err != nil {
-		return "", err
-	}
-
-	return markdown, nil
-}
diff --git a/internal/llm/tools/fetch.md b/internal/llm/tools/fetch.md
deleted file mode 100644
index ffb7bbaf..00000000
--- a/internal/llm/tools/fetch.md
+++ /dev/null
@@ -1,34 +0,0 @@
-Fetches content from a URL and returns it in the specified format.
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to download content from a URL
-- Helpful for retrieving documentation, API responses, or web content
-- Useful for getting external information to assist with tasks
-
-HOW TO USE:
-
-- Provide the URL to fetch content from
-- Specify the desired output format (text, markdown, or html)
-- Optionally set a timeout for the request
-
-FEATURES:
-
-- Supports three output formats: text, markdown, and html
-- Automatically handles HTTP redirects
-- Sets reasonable timeouts to prevent hanging
-- Validates input parameters before making requests
-
-LIMITATIONS:
-
-- Maximum response size is 5MB
-- Only supports HTTP and HTTPS protocols
-- Cannot handle authentication or cookies
-- Some websites may block automated requests
-
-TIPS:
-
-- Use text format for plain text content or simple API responses
-- Use markdown format for content that should be rendered with formatting
-- Use html format when you need the raw HTML structure
-- Set appropriate timeouts for potentially slow websites
diff --git a/internal/llm/tools/glob.go b/internal/llm/tools/glob.go
deleted file mode 100644
index 85207c28..00000000
--- a/internal/llm/tools/glob.go
+++ /dev/null
@@ -1,150 +0,0 @@
-package tools
-
-import (
-	"bytes"
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"log/slog"
-	"os/exec"
-	"path/filepath"
-	"sort"
-	"strings"
-
-	"github.com/charmbracelet/crush/internal/fsext"
-)
-
-const GlobToolName = "glob"
-
-//go:embed glob.md
-var globDescription []byte
-
-type GlobParams struct {
-	Pattern string `json:"pattern"`
-	Path    string `json:"path"`
-}
-
-type GlobResponseMetadata struct {
-	NumberOfFiles int  `json:"number_of_files"`
-	Truncated     bool `json:"truncated"`
-}
-
-type globTool struct {
-	workingDir string
-}
-
-func NewGlobTool(workingDir string) BaseTool {
-	return &globTool{
-		workingDir: workingDir,
-	}
-}
-
-func (g *globTool) Name() string {
-	return GlobToolName
-}
-
-func (g *globTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        GlobToolName,
-		Description: string(globDescription),
-		Parameters: map[string]any{
-			"pattern": map[string]any{
-				"type":        "string",
-				"description": "The glob pattern to match files against",
-			},
-			"path": map[string]any{
-				"type":        "string",
-				"description": "The directory to search in. Defaults to the current working directory.",
-			},
-		},
-		Required: []string{"pattern"},
-	}
-}
-
-func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params GlobParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	if params.Pattern == "" {
-		return NewTextErrorResponse("pattern is required"), nil
-	}
-
-	searchPath := params.Path
-	if searchPath == "" {
-		searchPath = g.workingDir
-	}
-
-	files, truncated, err := globFiles(ctx, params.Pattern, searchPath, 100)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error finding files: %w", err)
-	}
-
-	var output string
-	if len(files) == 0 {
-		output = "No files found"
-	} else {
-		output = strings.Join(files, "\n")
-		if truncated {
-			output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
-		}
-	}
-
-	return WithResponseMetadata(
-		NewTextResponse(output),
-		GlobResponseMetadata{
-			NumberOfFiles: len(files),
-			Truncated:     truncated,
-		},
-	), nil
-}
-
-func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
-	cmdRg := getRgCmd(ctx, pattern)
-	if cmdRg != nil {
-		cmdRg.Dir = searchPath
-		matches, err := runRipgrep(cmdRg, searchPath, limit)
-		if err == nil {
-			return matches, len(matches) >= limit && limit > 0, nil
-		}
-		slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
-	}
-
-	return fsext.GlobWithDoubleStar(pattern, searchPath, limit)
-}
-
-func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
-	out, err := cmd.CombinedOutput()
-	if err != nil {
-		if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
-			return nil, nil
-		}
-		return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
-	}
-
-	var matches []string
-	for p := range bytes.SplitSeq(out, []byte{0}) {
-		if len(p) == 0 {
-			continue
-		}
-		absPath := string(p)
-		if !filepath.IsAbs(absPath) {
-			absPath = filepath.Join(searchRoot, absPath)
-		}
-		if fsext.SkipHidden(absPath) {
-			continue
-		}
-		matches = append(matches, absPath)
-	}
-
-	sort.SliceStable(matches, func(i, j int) bool {
-		return len(matches[i]) < len(matches[j])
-	})
-
-	if limit > 0 && len(matches) > limit {
-		matches = matches[:limit]
-	}
-	return matches, nil
-}
diff --git a/internal/llm/tools/glob.md b/internal/llm/tools/glob.md
deleted file mode 100644
index 1d73d42c..00000000
--- a/internal/llm/tools/glob.md
+++ /dev/null
@@ -1,46 +0,0 @@
-Fast file pattern matching tool that finds files by name and pattern, returning matching paths sorted by modification time (newest first).
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to find files by name patterns or extensions
-- Great for finding specific file types across a directory structure
-- Useful for discovering files that match certain naming conventions
-
-HOW TO USE:
-
-- Provide a glob pattern to match against file paths
-- Optionally specify a starting directory (defaults to current working directory)
-- Results are sorted with most recently modified files first
-
-GLOB PATTERN SYNTAX:
-
-- '\*' matches any sequence of non-separator characters
-- '\*\*' matches any sequence of characters, including separators
-- '?' matches any single non-separator character
-- '[...]' matches any character in the brackets
-- '[!...]' matches any character not in the brackets
-
-COMMON PATTERN EXAMPLES:
-
-- '\*.js' - Find all JavaScript files in the current directory
-- '\*_/_.js' - Find all JavaScript files in any subdirectory
-- 'src/\*_/_.{ts,tsx}' - Find all TypeScript files in the src directory
-- '\*.{html,css,js}' - Find all HTML, CSS, and JS files
-
-LIMITATIONS:
-
-- Results are limited to 100 files (newest first)
-- Does not search file contents (use Grep tool for that)
-- Hidden files (starting with '.') are skipped
-
-WINDOWS NOTES:
-
-- Path separators are handled automatically (both / and \ work)
-- Uses ripgrep (rg) command if available, otherwise falls back to built-in Go implementation
-
-TIPS:
-
-- Patterns should use forward slashes (/) for cross-platform compatibility
-- For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with Grep
-- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
-- Always check if results are truncated and refine your search pattern if needed
diff --git a/internal/llm/tools/grep.md b/internal/llm/tools/grep.md
deleted file mode 100644
index c17a0ad1..00000000
--- a/internal/llm/tools/grep.md
+++ /dev/null
@@ -1,54 +0,0 @@
-Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first).
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to find files containing specific text or patterns
-- Great for searching code bases for function names, variable declarations, or error messages
-- Useful for finding all files that use a particular API or pattern
-
-HOW TO USE:
-
-- Provide a regex pattern to search for within file contents
-- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users)
-- Optionally specify a starting directory (defaults to current working directory)
-- Optionally provide an include pattern to filter which files to search
-- Results are sorted with most recently modified files first
-
-REGEX PATTERN SYNTAX (when literal_text=false):
-
-- Supports standard regular expression syntax
-- 'function' searches for the literal text "function"
-- 'log\..\*Error' finds text starting with "log." and ending with "Error"
-- 'import\s+.\*\s+from' finds import statements in JavaScript/TypeScript
-
-COMMON INCLUDE PATTERN EXAMPLES:
-
-- '\*.js' - Only search JavaScript files
-- '\*.{ts,tsx}' - Only search TypeScript files
-- '\*.go' - Only search Go files
-
-LIMITATIONS:
-
-- Results are limited to 100 files (newest first)
-- Performance depends on the number of files being searched
-- Very large binary files may be skipped
-- Hidden files (starting with '.') are skipped
-
-IGNORE FILE SUPPORT:
-
-- Respects .gitignore patterns to skip ignored files and directories
-- Respects .crushignore patterns for additional ignore rules
-- Both ignore files are automatically detected in the search root directory
-
-CROSS-PLATFORM NOTES:
-
-- Uses ripgrep (rg) command if available for better performance
-- Falls back to built-in Go implementation if ripgrep is not available
-- File paths are normalized automatically for cross-platform compatibility
-
-TIPS:
-
-- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
-- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
-- Always check if results are truncated and refine your search pattern if needed
-- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.
diff --git a/internal/llm/tools/ls.md b/internal/llm/tools/ls.md
deleted file mode 100644
index 798ddcc9..00000000
--- a/internal/llm/tools/ls.md
+++ /dev/null
@@ -1,40 +0,0 @@
-Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to explore the structure of a directory
-- Helpful for understanding the organization of a project
-- Good first step when getting familiar with a new codebase
-
-HOW TO USE:
-
-- Provide a path to list (defaults to current working directory)
-- Optionally specify glob patterns to ignore
-- Results are displayed in a tree structure
-
-FEATURES:
-
-- Displays a hierarchical view of files and directories
-- Automatically skips hidden files/directories (starting with '.')
-- Skips common system directories like **pycache**
-- Can filter out files matching specific patterns
-
-LIMITATIONS:
-
-- Results are limited to 1000 files
-- Very large directories will be truncated
-- Does not show file sizes or permissions
-- Cannot recursively list all directories in a large project
-
-WINDOWS NOTES:
-
-- Hidden file detection uses Unix convention (files starting with '.')
-- Windows-specific hidden files (with hidden attribute) are not automatically skipped
-- Common Windows directories like System32, Program Files are not in default ignore list
-- Path separators are handled automatically (both / and \ work)
-
-TIPS:
-
-- Use Glob tool for finding files by name patterns instead of browsing
-- Use Grep tool for searching file contents
-- Combine with other tools for more effective exploration
diff --git a/internal/llm/tools/multiedit.go b/internal/llm/tools/multiedit.go
deleted file mode 100644
index 1d6e415c..00000000
--- a/internal/llm/tools/multiedit.go
+++ /dev/null
@@ -1,424 +0,0 @@
-package tools
-
-import (
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"log/slog"
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/diff"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/history"
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/permission"
-)
-
-type MultiEditOperation struct {
-	OldString  string `json:"old_string"`
-	NewString  string `json:"new_string"`
-	ReplaceAll bool   `json:"replace_all,omitempty"`
-}
-
-type MultiEditParams struct {
-	FilePath string               `json:"file_path"`
-	Edits    []MultiEditOperation `json:"edits"`
-}
-
-type MultiEditPermissionsParams struct {
-	FilePath   string `json:"file_path"`
-	OldContent string `json:"old_content,omitempty"`
-	NewContent string `json:"new_content,omitempty"`
-}
-
-type MultiEditResponseMetadata struct {
-	Additions    int    `json:"additions"`
-	Removals     int    `json:"removals"`
-	OldContent   string `json:"old_content,omitempty"`
-	NewContent   string `json:"new_content,omitempty"`
-	EditsApplied int    `json:"edits_applied"`
-}
-
-type multiEditTool struct {
-	lspClients  *csync.Map[string, *lsp.Client]
-	permissions permission.Service
-	files       history.Service
-	workingDir  string
-}
-
-const MultiEditToolName = "multiedit"
-
-//go:embed multiedit.md
-var multieditDescription []byte
-
-func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) BaseTool {
-	return &multiEditTool{
-		lspClients:  lspClients,
-		permissions: permissions,
-		files:       files,
-		workingDir:  workingDir,
-	}
-}
-
-func (m *multiEditTool) Name() string {
-	return MultiEditToolName
-}
-
-func (m *multiEditTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        MultiEditToolName,
-		Description: string(multieditDescription),
-		Parameters: map[string]any{
-			"file_path": map[string]any{
-				"type":        "string",
-				"description": "The absolute path to the file to modify",
-			},
-			"edits": map[string]any{
-				"type": "array",
-				"items": map[string]any{
-					"type": "object",
-					"properties": map[string]any{
-						"old_string": map[string]any{
-							"type":        "string",
-							"description": "The text to replace",
-						},
-						"new_string": map[string]any{
-							"type":        "string",
-							"description": "The text to replace it with",
-						},
-						"replace_all": map[string]any{
-							"type":        "boolean",
-							"default":     false,
-							"description": "Replace all occurrences of old_string (default false).",
-						},
-					},
-					"required":             []string{"old_string", "new_string"},
-					"additionalProperties": false,
-				},
-				"minItems":    1,
-				"description": "Array of edit operations to perform sequentially on the file",
-			},
-		},
-		Required: []string{"file_path", "edits"},
-	}
-}
-
-func (m *multiEditTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params MultiEditParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse("invalid parameters"), nil
-	}
-
-	if params.FilePath == "" {
-		return NewTextErrorResponse("file_path is required"), nil
-	}
-
-	if len(params.Edits) == 0 {
-		return NewTextErrorResponse("at least one edit operation is required"), nil
-	}
-
-	if !filepath.IsAbs(params.FilePath) {
-		params.FilePath = filepath.Join(m.workingDir, params.FilePath)
-	}
-
-	// Validate all edits before applying any
-	if err := m.validateEdits(params.Edits); err != nil {
-		return NewTextErrorResponse(err.Error()), nil
-	}
-
-	var response ToolResponse
-	var err error
-
-	// Handle file creation case (first edit has empty old_string)
-	if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
-		response, err = m.processMultiEditWithCreation(ctx, params, call)
-	} else {
-		response, err = m.processMultiEditExistingFile(ctx, params, call)
-	}
-
-	if err != nil {
-		return response, err
-	}
-
-	if response.IsError {
-		return response, nil
-	}
-
-	// Notify LSP clients about the change
-	notifyLSPs(ctx, m.lspClients, params.FilePath)
-
-	// Wait for LSP diagnostics and add them to the response
-	text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
-	text += getDiagnostics(params.FilePath, m.lspClients)
-	response.Content = text
-	return response, nil
-}
-
-func (m *multiEditTool) validateEdits(edits []MultiEditOperation) error {
-	for i, edit := range edits {
-		if edit.OldString == edit.NewString {
-			return fmt.Errorf("edit %d: old_string and new_string are identical", i+1)
-		}
-		// Only the first edit can have empty old_string (for file creation)
-		if i > 0 && edit.OldString == "" {
-			return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
-		}
-	}
-	return nil
-}
-
-func (m *multiEditTool) processMultiEditWithCreation(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
-	// First edit creates the file
-	firstEdit := params.Edits[0]
-	if firstEdit.OldString != "" {
-		return NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
-	}
-
-	// Check if file already exists
-	if _, err := os.Stat(params.FilePath); err == nil {
-		return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
-	} else if !os.IsNotExist(err) {
-		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
-	}
-
-	// Create parent directories
-	dir := filepath.Dir(params.FilePath)
-	if err := os.MkdirAll(dir, 0o755); err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
-	}
-
-	// Start with the content from the first edit
-	currentContent := firstEdit.NewString
-
-	// Apply remaining edits to the content
-	for i := 1; i < len(params.Edits); i++ {
-		edit := params.Edits[i]
-		newContent, err := m.applyEditToContent(currentContent, edit)
-		if err != nil {
-			return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
-		}
-		currentContent = newContent
-	}
-
-	// Get session and message IDs
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
-	}
-
-	// Check permissions
-	_, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
-
-	p := m.permissions.Request(permission.CreatePermissionRequest{
-		SessionID:   sessionID,
-		Path:        fsext.PathOrPrefix(params.FilePath, m.workingDir),
-		ToolCallID:  call.ID,
-		ToolName:    MultiEditToolName,
-		Action:      "write",
-		Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
-		Params: MultiEditPermissionsParams{
-			FilePath:   params.FilePath,
-			OldContent: "",
-			NewContent: currentContent,
-		},
-	})
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	// Write the file
-	err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
-	}
-
-	// Update file history
-	_, err = m.files.Create(ctx, sessionID, params.FilePath, "")
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
-	}
-
-	_, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
-	if err != nil {
-		slog.Debug("Error creating file history version", "error", err)
-	}
-
-	recordFileWrite(params.FilePath)
-	recordFileRead(params.FilePath)
-
-	return WithResponseMetadata(
-		NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
-		MultiEditResponseMetadata{
-			OldContent:   "",
-			NewContent:   currentContent,
-			Additions:    additions,
-			Removals:     removals,
-			EditsApplied: len(params.Edits),
-		},
-	), nil
-}
-
-func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
-	// Validate file exists and is readable
-	fileInfo, err := os.Stat(params.FilePath)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
-		}
-		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
-	}
-
-	if fileInfo.IsDir() {
-		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
-	}
-
-	// Check if file was read before editing
-	if getLastReadTime(params.FilePath).IsZero() {
-		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
-	}
-
-	// Check if file was modified since last read
-	modTime := fileInfo.ModTime()
-	lastRead := getLastReadTime(params.FilePath)
-	if modTime.After(lastRead) {
-		return NewTextErrorResponse(
-			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
-				params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
-			)), nil
-	}
-
-	// Read current file content
-	content, err := os.ReadFile(params.FilePath)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
-	}
-
-	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
-	currentContent := oldContent
-
-	// Apply all edits sequentially
-	for i, edit := range params.Edits {
-		newContent, err := m.applyEditToContent(currentContent, edit)
-		if err != nil {
-			return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
-		}
-		currentContent = newContent
-	}
-
-	// Check if content actually changed
-	if oldContent == currentContent {
-		return NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
-	}
-
-	// Get session and message IDs
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for editing file")
-	}
-
-	// Generate diff and check permissions
-	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
-	p := m.permissions.Request(permission.CreatePermissionRequest{
-		SessionID:   sessionID,
-		Path:        fsext.PathOrPrefix(params.FilePath, m.workingDir),
-		ToolCallID:  call.ID,
-		ToolName:    MultiEditToolName,
-		Action:      "write",
-		Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
-		Params: MultiEditPermissionsParams{
-			FilePath:   params.FilePath,
-			OldContent: oldContent,
-			NewContent: currentContent,
-		},
-	})
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	if isCrlf {
-		currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
-	}
-
-	// Write the updated content
-	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
-	}
-
-	// Update file history
-	file, err := m.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
-	if err != nil {
-		_, err = m.files.Create(ctx, sessionID, params.FilePath, oldContent)
-		if err != nil {
-			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
-		}
-	}
-	if file.Content != oldContent {
-		// User manually changed the content, store an intermediate version
-		_, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
-		if err != nil {
-			slog.Debug("Error creating file history version", "error", err)
-		}
-	}
-
-	// Store the new version
-	_, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
-	if err != nil {
-		slog.Debug("Error creating file history version", "error", err)
-	}
-
-	recordFileWrite(params.FilePath)
-	recordFileRead(params.FilePath)
-
-	return WithResponseMetadata(
-		NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)),
-		MultiEditResponseMetadata{
-			OldContent:   oldContent,
-			NewContent:   currentContent,
-			Additions:    additions,
-			Removals:     removals,
-			EditsApplied: len(params.Edits),
-		},
-	), nil
-}
-
-func (m *multiEditTool) applyEditToContent(content string, edit MultiEditOperation) (string, error) {
-	if edit.OldString == "" && edit.NewString == "" {
-		return content, nil
-	}
-
-	if edit.OldString == "" {
-		return "", fmt.Errorf("old_string cannot be empty for content replacement")
-	}
-
-	var newContent string
-	var replacementCount int
-
-	if edit.ReplaceAll {
-		newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
-		replacementCount = strings.Count(content, edit.OldString)
-		if replacementCount == 0 {
-			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
-		}
-	} else {
-		index := strings.Index(content, edit.OldString)
-		if index == -1 {
-			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
-		}
-
-		lastIndex := strings.LastIndex(content, edit.OldString)
-		if index != lastIndex {
-			return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
-		}
-
-		newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
-		replacementCount = 1
-	}
-
-	return newContent, nil
-}
diff --git a/internal/llm/tools/multiedit.md b/internal/llm/tools/multiedit.md
deleted file mode 100644
index 4cc64f7a..00000000
--- a/internal/llm/tools/multiedit.md
+++ /dev/null
@@ -1,48 +0,0 @@
-This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file.
-
-Before using this tool:
-
-1. Use the Read tool to understand the file's contents and context
-
-2. Verify the directory path is correct
-
-To make multiple file edits, provide the following:
-
-1. file_path: The absolute path to the file to modify (must be absolute, not relative)
-2. edits: An array of edit operations to perform, where each edit contains:
-   - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
-   - new_string: The edited text to replace the old_string
-   - replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
-
-IMPORTANT:
-
-- All edits are applied in sequence, in the order they are provided
-- Each edit operates on the result of the previous edit
-- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
-- This tool is ideal when you need to make several changes to different parts of the same file
-
-CRITICAL REQUIREMENTS:
-
-1. All edits follow the same requirements as the single Edit tool
-2. The edits are atomic - either all succeed or none are applied
-3. Plan your edits carefully to avoid conflicts between sequential operations
-
-WARNING:
-
-- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
-- The tool will fail if edits.old_string and edits.new_string are the same
-- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
-
-When making edits:
-
-- Ensure all edits result in idiomatic, correct code
-- Do not leave the code in a broken state
-- Always use absolute file paths (starting with /)
-- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
-- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
-
-If you want to create a new file, use:
-
-- A new file path, including dir name if needed
-- First edit: empty old_string and the new file's contents as new_string
-- Subsequent edits: normal edit operations on the created content
diff --git a/internal/llm/tools/references.md b/internal/llm/tools/references.md
deleted file mode 100644
index 951ce71a..00000000
--- a/internal/llm/tools/references.md
+++ /dev/null
@@ -1,36 +0,0 @@
-Find all references to/usage of a symbol by name using the Language Server Protocol (LSP).
-
-WHEN TO USE THIS TOOL:
-
-- **ALWAYS USE THIS FIRST** when searching for where a function, method, variable, type, or constant is used
-- **DO NOT use grep/glob for symbol searches** - this tool is semantic-aware and much more accurate
-- Use when you need to find all usages of a specific symbol (function, variable, type, class, method, etc.)
-- More accurate than grep because it understands code semantics and scope
-- Finds only actual references, not string matches in comments or unrelated code
-- Helpful for understanding where a symbol is used throughout the codebase
-- Useful for refactoring or analyzing code dependencies
-- Good for finding all call sites of a function, method, type, package, constant, variable, etc.
-
-HOW TO USE:
-
-- Provide the symbol name (e.g., "MyFunction", "myVariable", "MyType")
-- Optionally specify a path to narrow the search to a specific directory
-- The tool will automatically find the symbol and locate all references
-
-FEATURES:
-
-- Returns all references grouped by file
-- Shows line and column numbers for each reference
-- Supports multiple programming languages through LSP
-- Automatically finds the symbol without needing exact position
-
-LIMITATIONS:
-
-- May not find references in files that haven't been opened or indexed
-- Results depend on the LSP server's capabilities
-
-TIPS:
-
-- **Use this tool instead of grep when looking for symbol references** - it's more accurate and semantic-aware
-- Simply provide the symbol name and let the tool find it for you
-- This tool understands code structure, so it won't match unrelated strings or comments
diff --git a/internal/llm/tools/sourcegraph.go b/internal/llm/tools/sourcegraph.go
deleted file mode 100644
index aea6b1ae..00000000
--- a/internal/llm/tools/sourcegraph.go
+++ /dev/null
@@ -1,302 +0,0 @@
-package tools
-
-import (
-	"bytes"
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"strings"
-	"time"
-)
-
-type SourcegraphParams struct {
-	Query         string `json:"query"`
-	Count         int    `json:"count,omitempty"`
-	ContextWindow int    `json:"context_window,omitempty"`
-	Timeout       int    `json:"timeout,omitempty"`
-}
-
-type SourcegraphResponseMetadata struct {
-	NumberOfMatches int  `json:"number_of_matches"`
-	Truncated       bool `json:"truncated"`
-}
-
-type sourcegraphTool struct {
-	client *http.Client
-}
-
-const SourcegraphToolName = "sourcegraph"
-
-//go:embed sourcegraph.md
-var sourcegraphDescription []byte
-
-func NewSourcegraphTool() BaseTool {
-	return &sourcegraphTool{
-		client: &http.Client{
-			Timeout: 30 * time.Second,
-			Transport: &http.Transport{
-				MaxIdleConns:        100,
-				MaxIdleConnsPerHost: 10,
-				IdleConnTimeout:     90 * time.Second,
-			},
-		},
-	}
-}
-
-func (t *sourcegraphTool) Name() string {
-	return SourcegraphToolName
-}
-
-func (t *sourcegraphTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        SourcegraphToolName,
-		Description: string(sourcegraphDescription),
-		Parameters: map[string]any{
-			"query": map[string]any{
-				"type":        "string",
-				"description": "The Sourcegraph search query",
-			},
-			"count": map[string]any{
-				"type":        "number",
-				"description": "Optional number of results to return (default: 10, max: 20)",
-			},
-			"context_window": map[string]any{
-				"type":        "number",
-				"description": "The context around the match to return (default: 10 lines)",
-			},
-			"timeout": map[string]any{
-				"type":        "number",
-				"description": "Optional timeout in seconds (max 120)",
-			},
-		},
-		Required: []string{"query"},
-	}
-}
-
-func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params SourcegraphParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse("Failed to parse sourcegraph parameters: " + err.Error()), nil
-	}
-
-	if params.Query == "" {
-		return NewTextErrorResponse("Query parameter is required"), nil
-	}
-
-	if params.Count <= 0 {
-		params.Count = 10
-	} else if params.Count > 20 {
-		params.Count = 20 // Limit to 20 results
-	}
-
-	if params.ContextWindow <= 0 {
-		params.ContextWindow = 10 // Default context window
-	}
-
-	// Handle timeout with context
-	requestCtx := ctx
-	if params.Timeout > 0 {
-		maxTimeout := 120 // 2 minutes
-		if params.Timeout > maxTimeout {
-			params.Timeout = maxTimeout
-		}
-		var cancel context.CancelFunc
-		requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
-		defer cancel()
-	}
-
-	type graphqlRequest struct {
-		Query     string `json:"query"`
-		Variables struct {
-			Query string `json:"query"`
-		} `json:"variables"`
-	}
-
-	request := graphqlRequest{
-		Query: "query Search($query: String!) { search(query: $query, version: V2, patternType: keyword ) { results { matchCount, limitHit, resultCount, approximateResultCount, missing { name }, timedout { name }, indexUnavailable, results { __typename, ... on FileMatch { repository { name }, file { path, url, content }, lineMatches { preview, lineNumber, offsetAndLengths } } } } } }",
-	}
-	request.Variables.Query = params.Query
-
-	graphqlQueryBytes, err := json.Marshal(request)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err)
-	}
-	graphqlQuery := string(graphqlQueryBytes)
-
-	req, err := http.NewRequestWithContext(
-		requestCtx,
-		"POST",
-		"https://sourcegraph.com/.api/graphql",
-		bytes.NewBuffer([]byte(graphqlQuery)),
-	)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
-	}
-
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("User-Agent", "crush/1.0")
-
-	resp, err := t.client.Do(req)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		body, _ := io.ReadAll(resp.Body)
-		if len(body) > 0 {
-			return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil
-		}
-
-		return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
-	}
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to read response body: %w", err)
-	}
-
-	var result map[string]any
-	if err = json.Unmarshal(body, &result); err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err)
-	}
-
-	formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
-	if err != nil {
-		return NewTextErrorResponse("Failed to format results: " + err.Error()), nil
-	}
-
-	return NewTextResponse(formattedResults), nil
-}
-
-func formatSourcegraphResults(result map[string]any, contextWindow int) (string, error) {
-	var buffer strings.Builder
-
-	if errors, ok := result["errors"].([]any); ok && len(errors) > 0 {
-		buffer.WriteString("## Sourcegraph API Error\n\n")
-		for _, err := range errors {
-			if errMap, ok := err.(map[string]any); ok {
-				if message, ok := errMap["message"].(string); ok {
-					buffer.WriteString(fmt.Sprintf("- %s\n", message))
-				}
-			}
-		}
-		return buffer.String(), nil
-	}
-
-	data, ok := result["data"].(map[string]any)
-	if !ok {
-		return "", fmt.Errorf("invalid response format: missing data field")
-	}
-
-	search, ok := data["search"].(map[string]any)
-	if !ok {
-		return "", fmt.Errorf("invalid response format: missing search field")
-	}
-
-	searchResults, ok := search["results"].(map[string]any)
-	if !ok {
-		return "", fmt.Errorf("invalid response format: missing results field")
-	}
-
-	matchCount, _ := searchResults["matchCount"].(float64)
-	resultCount, _ := searchResults["resultCount"].(float64)
-	limitHit, _ := searchResults["limitHit"].(bool)
-
-	buffer.WriteString("# Sourcegraph Search Results\n\n")
-	buffer.WriteString(fmt.Sprintf("Found %d matches across %d results\n", int(matchCount), int(resultCount)))
-
-	if limitHit {
-		buffer.WriteString("(Result limit reached, try a more specific query)\n")
-	}
-
-	buffer.WriteString("\n")
-
-	results, ok := searchResults["results"].([]any)
-	if !ok || len(results) == 0 {
-		buffer.WriteString("No results found. Try a different query.\n")
-		return buffer.String(), nil
-	}
-
-	maxResults := 10
-	if len(results) > maxResults {
-		results = results[:maxResults]
-	}
-
-	for i, res := range results {
-		fileMatch, ok := res.(map[string]any)
-		if !ok {
-			continue
-		}
-
-		typeName, _ := fileMatch["__typename"].(string)
-		if typeName != "FileMatch" {
-			continue
-		}
-
-		repo, _ := fileMatch["repository"].(map[string]any)
-		file, _ := fileMatch["file"].(map[string]any)
-		lineMatches, _ := fileMatch["lineMatches"].([]any)
-
-		if repo == nil || file == nil {
-			continue
-		}
-
-		repoName, _ := repo["name"].(string)
-		filePath, _ := file["path"].(string)
-		fileURL, _ := file["url"].(string)
-		fileContent, _ := file["content"].(string)
-
-		buffer.WriteString(fmt.Sprintf("## Result %d: %s/%s\n\n", i+1, repoName, filePath))
-
-		if fileURL != "" {
-			buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL))
-		}
-
-		if len(lineMatches) > 0 {
-			for _, lm := range lineMatches {
-				lineMatch, ok := lm.(map[string]any)
-				if !ok {
-					continue
-				}
-
-				lineNumber, _ := lineMatch["lineNumber"].(float64)
-				preview, _ := lineMatch["preview"].(string)
-
-				if fileContent != "" {
-					lines := strings.Split(fileContent, "\n")
-
-					buffer.WriteString("```\n")
-
-					startLine := max(1, int(lineNumber)-contextWindow)
-
-					for j := startLine - 1; j < int(lineNumber)-1 && j < len(lines); j++ {
-						if j >= 0 {
-							buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
-						}
-					}
-
-					buffer.WriteString(fmt.Sprintf("%d|  %s\n", int(lineNumber), preview))
-
-					endLine := int(lineNumber) + contextWindow
-
-					for j := int(lineNumber); j < endLine && j < len(lines); j++ {
-						if j < len(lines) {
-							buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
-						}
-					}
-
-					buffer.WriteString("```\n\n")
-				} else {
-					buffer.WriteString("```\n")
-					buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
-					buffer.WriteString("```\n\n")
-				}
-			}
-		}
-	}
-
-	return buffer.String(), nil
-}
diff --git a/internal/llm/tools/sourcegraph.md b/internal/llm/tools/sourcegraph.md
deleted file mode 100644
index ec6610d7..00000000
--- a/internal/llm/tools/sourcegraph.md
+++ /dev/null
@@ -1,102 +0,0 @@
-Search code across public repositories using Sourcegraph's GraphQL API.
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to find code examples or implementations across public repositories
-- Helpful for researching how others have solved similar problems
-- Useful for discovering patterns and best practices in open source code
-
-HOW TO USE:
-
-- Provide a search query using Sourcegraph's query syntax
-- Optionally specify the number of results to return (default: 10)
-- Optionally set a timeout for the request
-
-QUERY SYNTAX:
-
-- Basic search: "fmt.Println" searches for exact matches
-- File filters: "file:.go fmt.Println" limits to Go files
-- Repository filters: "repo:^github\.com/golang/go$ fmt.Println" limits to specific repos
-- Language filters: "lang:go fmt.Println" limits to Go code
-- Boolean operators: "fmt.Println AND log.Fatal" for combined terms
-- Regular expressions: "fmt\.(Print|Printf|Println)" for pattern matching
-- Quoted strings: "\"exact phrase\"" for exact phrase matching
-- Exclude filters: "-file:test" or "-repo:forks" to exclude matches
-
-ADVANCED FILTERS:
-
-- Repository filters:
-  - "repo:name" - Match repositories with name containing "name"
-  - "repo:^github\.com/org/repo$" - Exact repository match
-  - "repo:org/repo@branch" - Search specific branch
-  - "repo:org/repo rev:branch" - Alternative branch syntax
-  - "-repo:name" - Exclude repositories
-  - "fork:yes" or "fork:only" - Include or only show forks
-  - "archived:yes" or "archived:only" - Include or only show archived repos
-  - "visibility:public" or "visibility:private" - Filter by visibility
-
-- File filters:
-  - "file:\.js$" - Files with .js extension
-  - "file:internal/" - Files in internal directory
-  - "-file:test" - Exclude test files
-  - "file:has.content(Copyright)" - Files containing "Copyright"
-  - "file:has.contributor([email protected])" - Files with specific contributor
-
-- Content filters:
-  - "content:\"exact string\"" - Search for exact string
-  - "-content:\"unwanted\"" - Exclude files with unwanted content
-  - "case:yes" - Case-sensitive search
-
-- Type filters:
-  - "type:symbol" - Search for symbols (functions, classes, etc.)
-  - "type:file" - Search file content only
-  - "type:path" - Search filenames only
-  - "type:diff" - Search code changes
-  - "type:commit" - Search commit messages
-
-- Commit/diff search:
-  - "after:\"1 month ago\"" - Commits after date
-  - "before:\"2023-01-01\"" - Commits before date
-  - "author:name" - Commits by author
-  - "message:\"fix bug\"" - Commits with message
-
-- Result selection:
-  - "select:repo" - Show only repository names
-  - "select:file" - Show only file paths
-  - "select:content" - Show only matching content
-  - "select:symbol" - Show only matching symbols
-
-- Result control:
-  - "count:100" - Return up to 100 results
-  - "count:all" - Return all results
-  - "timeout:30s" - Set search timeout
-
-EXAMPLES:
-
-- "file:.go context.WithTimeout" - Find Go code using context.WithTimeout
-- "lang:typescript useState type:symbol" - Find TypeScript React useState hooks
-- "repo:^github\.com/kubernetes/kubernetes$ pod list type:file" - Find Kubernetes files related to pod listing
-- "repo:sourcegraph/sourcegraph$ after:\"3 months ago\" type:diff database" - Recent changes to database code
-- "file:Dockerfile (alpine OR ubuntu) -content:alpine:latest" - Dockerfiles with specific base images
-- "repo:has.path(\.py) file:requirements.txt tensorflow" - Python projects using TensorFlow
-
-BOOLEAN OPERATORS:
-
-- "term1 AND term2" - Results containing both terms
-- "term1 OR term2" - Results containing either term
-- "term1 NOT term2" - Results with term1 but not term2
-- "term1 and (term2 or term3)" - Grouping with parentheses
-
-LIMITATIONS:
-
-- Only searches public repositories
-- Rate limits may apply
-- Complex queries may take longer to execute
-- Maximum of 20 results per query
-
-TIPS:
-
-- Use specific file extensions to narrow results
-- Add repo: filters for more targeted searches
-- Use type:symbol to find function/method definitions
-- Use type:file to find relevant files
diff --git a/internal/llm/tools/tools.go b/internal/llm/tools/tools.go
deleted file mode 100644
index 41c05156..00000000
--- a/internal/llm/tools/tools.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package tools
-
-import (
-	"context"
-	"encoding/json"
-)
-
-type ToolInfo struct {
-	Name        string
-	Description string
-	Parameters  map[string]any
-	Required    []string
-}
-
-type toolResponseType string
-
-type (
-	sessionIDContextKey string
-	messageIDContextKey string
-)
-
-const (
-	ToolResponseTypeText  toolResponseType = "text"
-	ToolResponseTypeImage toolResponseType = "image"
-
-	SessionIDContextKey sessionIDContextKey = "session_id"
-	MessageIDContextKey messageIDContextKey = "message_id"
-)
-
-type ToolResponse struct {
-	Type     toolResponseType `json:"type"`
-	Content  string           `json:"content"`
-	Metadata string           `json:"metadata,omitempty"`
-	IsError  bool             `json:"is_error"`
-}
-
-func NewTextResponse(content string) ToolResponse {
-	return ToolResponse{
-		Type:    ToolResponseTypeText,
-		Content: content,
-	}
-}
-
-func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
-	if metadata != nil {
-		metadataBytes, err := json.Marshal(metadata)
-		if err != nil {
-			return response
-		}
-		response.Metadata = string(metadataBytes)
-	}
-	return response
-}
-
-func NewTextErrorResponse(content string) ToolResponse {
-	return ToolResponse{
-		Type:    ToolResponseTypeText,
-		Content: content,
-		IsError: true,
-	}
-}
-
-type ToolCall struct {
-	ID    string `json:"id"`
-	Name  string `json:"name"`
-	Input string `json:"input"`
-}
-
-type BaseTool interface {
-	Info() ToolInfo
-	Name() string
-	Run(ctx context.Context, params ToolCall) (ToolResponse, error)
-}
-
-func GetContextValues(ctx context.Context) (string, string) {
-	sessionID := ctx.Value(SessionIDContextKey)
-	messageID := ctx.Value(MessageIDContextKey)
-	if sessionID == nil {
-		return "", ""
-	}
-	if messageID == nil {
-		return sessionID.(string), ""
-	}
-	return sessionID.(string), messageID.(string)
-}
diff --git a/internal/llm/tools/view.go b/internal/llm/tools/view.go
deleted file mode 100644
index c3824bf1..00000000
--- a/internal/llm/tools/view.go
+++ /dev/null
@@ -1,343 +0,0 @@
-package tools
-
-import (
-	"bufio"
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"io"
-	"os"
-	"path/filepath"
-	"strings"
-	"unicode/utf8"
-
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/permission"
-)
-
-//go:embed view.md
-var viewDescription []byte
-
-type ViewParams struct {
-	FilePath string `json:"file_path"`
-	Offset   int    `json:"offset"`
-	Limit    int    `json:"limit"`
-}
-
-type ViewPermissionsParams struct {
-	FilePath string `json:"file_path"`
-	Offset   int    `json:"offset"`
-	Limit    int    `json:"limit"`
-}
-
-type viewTool struct {
-	lspClients  *csync.Map[string, *lsp.Client]
-	workingDir  string
-	permissions permission.Service
-}
-
-type ViewResponseMetadata struct {
-	FilePath string `json:"file_path"`
-	Content  string `json:"content"`
-}
-
-const (
-	ViewToolName     = "view"
-	MaxReadSize      = 250 * 1024
-	DefaultReadLimit = 2000
-	MaxLineLength    = 2000
-)
-
-func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) BaseTool {
-	return &viewTool{
-		lspClients:  lspClients,
-		workingDir:  workingDir,
-		permissions: permissions,
-	}
-}
-
-func (v *viewTool) Name() string {
-	return ViewToolName
-}
-
-func (v *viewTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        ViewToolName,
-		Description: string(viewDescription),
-		Parameters: map[string]any{
-			"file_path": map[string]any{
-				"type":        "string",
-				"description": "The path to the file to read",
-			},
-			"offset": map[string]any{
-				"type":        "integer",
-				"description": "The line number to start reading from (0-based)",
-			},
-			"limit": map[string]any{
-				"type":        "integer",
-				"description": "The number of lines to read (defaults to 2000)",
-			},
-		},
-		Required: []string{"file_path"},
-	}
-}
-
-// Run implements Tool.
-func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params ViewParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	if params.FilePath == "" {
-		return NewTextErrorResponse("file_path is required"), nil
-	}
-
-	// Handle relative paths
-	filePath := params.FilePath
-	if !filepath.IsAbs(filePath) {
-		filePath = filepath.Join(v.workingDir, filePath)
-	}
-
-	// Check if file is outside working directory and request permission if needed
-	absWorkingDir, err := filepath.Abs(v.workingDir)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
-	}
-
-	absFilePath, err := filepath.Abs(filePath)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
-	}
-
-	relPath, err := filepath.Rel(absWorkingDir, absFilePath)
-	if err != nil || strings.HasPrefix(relPath, "..") {
-		// File is outside working directory, request permission
-		sessionID, messageID := GetContextValues(ctx)
-		if sessionID == "" || messageID == "" {
-			return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing files outside working directory")
-		}
-
-		granted := v.permissions.Request(
-			permission.CreatePermissionRequest{
-				SessionID:   sessionID,
-				Path:        absFilePath,
-				ToolCallID:  call.ID,
-				ToolName:    ViewToolName,
-				Action:      "read",
-				Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
-				Params:      ViewPermissionsParams(params),
-			},
-		)
-
-		if !granted {
-			return ToolResponse{}, permission.ErrorPermissionDenied
-		}
-	}
-
-	// Check if file exists
-	fileInfo, err := os.Stat(filePath)
-	if err != nil {
-		if os.IsNotExist(err) {
-			// Try to offer suggestions for similarly named files
-			dir := filepath.Dir(filePath)
-			base := filepath.Base(filePath)
-
-			dirEntries, dirErr := os.ReadDir(dir)
-			if dirErr == nil {
-				var suggestions []string
-				for _, entry := range dirEntries {
-					if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
-						strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
-						suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
-						if len(suggestions) >= 3 {
-							break
-						}
-					}
-				}
-
-				if len(suggestions) > 0 {
-					return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
-						filePath, strings.Join(suggestions, "\n"))), nil
-				}
-			}
-
-			return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
-		}
-		return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
-	}
-
-	// Check if it's a directory
-	if fileInfo.IsDir() {
-		return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
-	}
-
-	// Check file size
-	if fileInfo.Size() > MaxReadSize {
-		return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
-			fileInfo.Size(), MaxReadSize)), nil
-	}
-
-	// Set default limit if not provided
-	if params.Limit <= 0 {
-		params.Limit = DefaultReadLimit
-	}
-
-	// Check if it's an image file
-	isImage, imageType := isImageFile(filePath)
-	// TODO: handle images
-	if isImage {
-		return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
-	}
-
-	// Read the file content
-	content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
-	isValidUt8 := utf8.ValidString(content)
-	if !isValidUt8 {
-		return NewTextErrorResponse("File content is not valid UTF-8"), nil
-	}
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
-	}
-
-	notifyLSPs(ctx, v.lspClients, filePath)
-	output := "<file>\n"
-	// Format the output with line numbers
-	output += addLineNumbers(content, params.Offset+1)
-
-	// Add a note if the content was truncated
-	if lineCount > params.Offset+len(strings.Split(content, "\n")) {
-		output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
-			params.Offset+len(strings.Split(content, "\n")))
-	}
-	output += "\n</file>\n"
-	output += getDiagnostics(filePath, v.lspClients)
-	recordFileRead(filePath)
-	return WithResponseMetadata(
-		NewTextResponse(output),
-		ViewResponseMetadata{
-			FilePath: filePath,
-			Content:  content,
-		},
-	), nil
-}
-
-func addLineNumbers(content string, startLine int) string {
-	if content == "" {
-		return ""
-	}
-
-	lines := strings.Split(content, "\n")
-
-	var result []string
-	for i, line := range lines {
-		line = strings.TrimSuffix(line, "\r")
-
-		lineNum := i + startLine
-		numStr := fmt.Sprintf("%d", lineNum)
-
-		if len(numStr) >= 6 {
-			result = append(result, fmt.Sprintf("%s|%s", numStr, line))
-		} else {
-			paddedNum := fmt.Sprintf("%6s", numStr)
-			result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
-		}
-	}
-
-	return strings.Join(result, "\n")
-}
-
-func readTextFile(filePath string, offset, limit int) (string, int, error) {
-	file, err := os.Open(filePath)
-	if err != nil {
-		return "", 0, err
-	}
-	defer file.Close()
-
-	lineCount := 0
-
-	scanner := NewLineScanner(file)
-	if offset > 0 {
-		for lineCount < offset && scanner.Scan() {
-			lineCount++
-		}
-		if err = scanner.Err(); err != nil {
-			return "", 0, err
-		}
-	}
-
-	if offset == 0 {
-		_, err = file.Seek(0, io.SeekStart)
-		if err != nil {
-			return "", 0, err
-		}
-	}
-
-	// Pre-allocate slice with expected capacity
-	lines := make([]string, 0, limit)
-	lineCount = offset
-
-	for scanner.Scan() && len(lines) < limit {
-		lineCount++
-		lineText := scanner.Text()
-		if len(lineText) > MaxLineLength {
-			lineText = lineText[:MaxLineLength] + "..."
-		}
-		lines = append(lines, lineText)
-	}
-
-	// Continue scanning to get total line count
-	for scanner.Scan() {
-		lineCount++
-	}
-
-	if err := scanner.Err(); err != nil {
-		return "", 0, err
-	}
-
-	return strings.Join(lines, "\n"), lineCount, nil
-}
-
-func isImageFile(filePath string) (bool, string) {
-	ext := strings.ToLower(filepath.Ext(filePath))
-	switch ext {
-	case ".jpg", ".jpeg":
-		return true, "JPEG"
-	case ".png":
-		return true, "PNG"
-	case ".gif":
-		return true, "GIF"
-	case ".bmp":
-		return true, "BMP"
-	case ".svg":
-		return true, "SVG"
-	case ".webp":
-		return true, "WebP"
-	default:
-		return false, ""
-	}
-}
-
-type LineScanner struct {
-	scanner *bufio.Scanner
-}
-
-func NewLineScanner(r io.Reader) *LineScanner {
-	return &LineScanner{
-		scanner: bufio.NewScanner(r),
-	}
-}
-
-func (s *LineScanner) Scan() bool {
-	return s.scanner.Scan()
-}
-
-func (s *LineScanner) Text() string {
-	return s.scanner.Text()
-}
-
-func (s *LineScanner) Err() error {
-	return s.scanner.Err()
-}
diff --git a/internal/llm/tools/view.md b/internal/llm/tools/view.md
deleted file mode 100644
index 2179b778..00000000
--- a/internal/llm/tools/view.md
+++ /dev/null
@@ -1,42 +0,0 @@
-File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to read the contents of a specific file
-- Helpful for examining source code, configuration files, or log files
-- Perfect for looking at text-based file formats
-
-HOW TO USE:
-
-- Provide the path to the file you want to view
-- Optionally specify an offset to start reading from a specific line
-- Optionally specify a limit to control how many lines are read
-- Do not use this for directories use the ls tool instead
-
-FEATURES:
-
-- Displays file contents with line numbers for easy reference
-- Can read from any position in a file using the offset parameter
-- Handles large files by limiting the number of lines read
-- Automatically truncates very long lines for better display
-- Suggests similar file names when the requested file isn't found
-
-LIMITATIONS:
-
-- Maximum file size is 250KB
-- Default reading limit is 2000 lines
-- Lines longer than 2000 characters are truncated
-- Cannot display binary files or images
-- Images can be identified but not displayed
-
-WINDOWS NOTES:
-
-- Handles both Windows (CRLF) and Unix (LF) line endings automatically
-- File paths work with both forward slashes (/) and backslashes (\)
-- Text encoding is detected automatically for most common formats
-
-TIPS:
-
-- Use with Glob tool to first find files you want to view
-- For code exploration, first use Grep to find relevant files, then View to examine them
-- When viewing large files, use the offset parameter to read specific sections
diff --git a/internal/llm/tools/write.go b/internal/llm/tools/write.go
deleted file mode 100644
index 36ea13d2..00000000
--- a/internal/llm/tools/write.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package tools
-
-import (
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"log/slog"
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/diff"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/history"
-
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/permission"
-)
-
-//go:embed write.md
-var writeDescription []byte
-
-type WriteParams struct {
-	FilePath string `json:"file_path"`
-	Content  string `json:"content"`
-}
-
-type WritePermissionsParams struct {
-	FilePath   string `json:"file_path"`
-	OldContent string `json:"old_content,omitempty"`
-	NewContent string `json:"new_content,omitempty"`
-}
-
-type writeTool struct {
-	lspClients  *csync.Map[string, *lsp.Client]
-	permissions permission.Service
-	files       history.Service
-	workingDir  string
-}
-
-type WriteResponseMetadata struct {
-	Diff      string `json:"diff"`
-	Additions int    `json:"additions"`
-	Removals  int    `json:"removals"`
-}
-
-const WriteToolName = "write"
-
-func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) BaseTool {
-	return &writeTool{
-		lspClients:  lspClients,
-		permissions: permissions,
-		files:       files,
-		workingDir:  workingDir,
-	}
-}
-
-func (w *writeTool) Name() string {
-	return WriteToolName
-}
-
-func (w *writeTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        WriteToolName,
-		Description: string(writeDescription),
-		Parameters: map[string]any{
-			"file_path": map[string]any{
-				"type":        "string",
-				"description": "The path to the file to write",
-			},
-			"content": map[string]any{
-				"type":        "string",
-				"description": "The content to write to the file",
-			},
-		},
-		Required: []string{"file_path", "content"},
-	}
-}
-
-func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params WriteParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	if params.FilePath == "" {
-		return NewTextErrorResponse("file_path is required"), nil
-	}
-
-	if params.Content == "" {
-		return NewTextErrorResponse("content is required"), nil
-	}
-
-	filePath := params.FilePath
-	if !filepath.IsAbs(filePath) {
-		filePath = filepath.Join(w.workingDir, filePath)
-	}
-
-	fileInfo, err := os.Stat(filePath)
-	if err == nil {
-		if fileInfo.IsDir() {
-			return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
-		}
-
-		modTime := fileInfo.ModTime()
-		lastRead := getLastReadTime(filePath)
-		if modTime.After(lastRead) {
-			return NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
-				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
-		}
-
-		oldContent, readErr := os.ReadFile(filePath)
-		if readErr == nil && string(oldContent) == params.Content {
-			return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
-		}
-	} else if !os.IsNotExist(err) {
-		return ToolResponse{}, fmt.Errorf("error checking file: %w", err)
-	}
-
-	dir := filepath.Dir(filePath)
-	if err = os.MkdirAll(dir, 0o755); err != nil {
-		return ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
-	}
-
-	oldContent := ""
-	if fileInfo != nil && !fileInfo.IsDir() {
-		oldBytes, readErr := os.ReadFile(filePath)
-		if readErr == nil {
-			oldContent = string(oldBytes)
-		}
-	}
-
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session_id and message_id are required")
-	}
-
-	diff, additions, removals := diff.GenerateDiff(
-		oldContent,
-		params.Content,
-		strings.TrimPrefix(filePath, w.workingDir),
-	)
-
-	p := w.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			Path:        fsext.PathOrPrefix(filePath, w.workingDir),
-			ToolCallID:  call.ID,
-			ToolName:    WriteToolName,
-			Action:      "write",
-			Description: fmt.Sprintf("Create file %s", filePath),
-			Params: WritePermissionsParams{
-				FilePath:   filePath,
-				OldContent: oldContent,
-				NewContent: params.Content,
-			},
-		},
-	)
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	err = os.WriteFile(filePath, []byte(params.Content), 0o644)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
-	}
-
-	// Check if file exists in history
-	file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
-	if err != nil {
-		_, err = w.files.Create(ctx, sessionID, filePath, oldContent)
-		if err != nil {
-			// Log error but don't fail the operation
-			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
-		}
-	}
-	if file.Content != oldContent {
-		// User Manually changed the content store an intermediate version
-		_, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
-		if err != nil {
-			slog.Debug("Error creating file history version", "error", err)
-		}
-	}
-	// Store the new version
-	_, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
-	if err != nil {
-		slog.Debug("Error creating file history version", "error", err)
-	}
-
-	recordFileWrite(filePath)
-	recordFileRead(filePath)
-
-	notifyLSPs(ctx, w.lspClients, params.FilePath)
-
-	result := fmt.Sprintf("File successfully written: %s", filePath)
-	result = fmt.Sprintf("<result>\n%s\n</result>", result)
-	result += getDiagnostics(filePath, w.lspClients)
-	return WithResponseMetadata(NewTextResponse(result),
-		WriteResponseMetadata{
-			Diff:      diff,
-			Additions: additions,
-			Removals:  removals,
-		},
-	), nil
-}
diff --git a/internal/llm/tools/write.md b/internal/llm/tools/write.md
deleted file mode 100644
index b8cc811f..00000000
--- a/internal/llm/tools/write.md
+++ /dev/null
@@ -1,38 +0,0 @@
-File writing tool that creates or updates files in the filesystem, allowing you to save or modify text content.
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to create a new file
-- Helpful for updating existing files with modified content
-- Perfect for saving generated code, configurations, or text data
-
-HOW TO USE:
-
-- Provide the path to the file you want to write
-- Include the content to be written to the file
-- The tool will create any necessary parent directories
-
-FEATURES:
-
-- Can create new files or overwrite existing ones
-- Creates parent directories automatically if they don't exist
-- Checks if the file has been modified since last read for safety
-- Avoids unnecessary writes when content hasn't changed
-
-LIMITATIONS:
-
-- You should read a file before writing to it to avoid conflicts
-- Cannot append to files (rewrites the entire file)
-
-WINDOWS NOTES:
-
-- File permissions (0o755, 0o644) are Unix-style but work on Windows with appropriate translations
-- Use forward slashes (/) in paths for cross-platform compatibility
-- Windows file attributes and permissions are handled automatically by the Go runtime
-
-TIPS:
-
-- Use the View tool first to examine existing files before modifying them
-- Use the LS tool to verify the correct location when creating new files
-- Combine with Glob and Grep tools to find and modify multiple files
-- Always include descriptive comments when making changes to existing code
diff --git a/internal/log/http.go b/internal/log/http.go
index 46c4b42a..a4564ffd 100644
--- a/internal/log/http.go
+++ b/internal/log/http.go
@@ -39,12 +39,14 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro
 		return nil, err
 	}
 
-	slog.Debug(
-		"HTTP Request",
-		"method", req.Method,
-		"url", req.URL,
-		"body", bodyToString(save),
-	)
+	if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
+		slog.Debug(
+			"HTTP Request",
+			"method", req.Method,
+			"url", req.URL,
+			"body", bodyToString(save),
+		)
+	}
 
 	start := time.Now()
 	resp, err := h.Transport.RoundTrip(req)
@@ -61,16 +63,18 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro
 	}
 
 	save, resp.Body, err = drainBody(resp.Body)
-	slog.Debug(
-		"HTTP Response",
-		"status_code", resp.StatusCode,
-		"status", resp.Status,
-		"headers", formatHeaders(resp.Header),
-		"body", bodyToString(save),
-		"content_length", resp.ContentLength,
-		"duration_ms", duration.Milliseconds(),
-		"error", err,
-	)
+	if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
+		slog.Debug(
+			"HTTP Response",
+			"status_code", resp.StatusCode,
+			"status", resp.Status,
+			"headers", formatHeaders(resp.Header),
+			"body", bodyToString(save),
+			"content_length", resp.ContentLength,
+			"duration_ms", duration.Milliseconds(),
+			"error", err,
+		)
+	}
 	return resp, err
 }
 
@@ -84,7 +88,7 @@ func bodyToString(body io.ReadCloser) string {
 		return ""
 	}
 	var b bytes.Buffer
-	if json.Compact(&b, bytes.TrimSpace(src)) != nil {
+	if json.Indent(&b, bytes.TrimSpace(src), "", "  ") != nil {
 		// not json probably
 		return string(src)
 	}
diff --git a/internal/message/content.go b/internal/message/content.go
index b3f21218..7f356782 100644
--- a/internal/message/content.go
+++ b/internal/message/content.go
@@ -2,9 +2,15 @@ package message
 
 import (
 	"encoding/base64"
+	"errors"
 	"slices"
+	"strings"
 	"time"
 
+	"charm.land/fantasy"
+	"charm.land/fantasy/providers/anthropic"
+	"charm.land/fantasy/providers/google"
+	"charm.land/fantasy/providers/openai"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 )
 
@@ -36,10 +42,12 @@ type ContentPart interface {
 }
 
 type ReasoningContent struct {
-	Thinking   string `json:"thinking"`
-	Signature  string `json:"signature"`
-	StartedAt  int64  `json:"started_at,omitempty"`
-	FinishedAt int64  `json:"finished_at,omitempty"`
+	Thinking         string                             `json:"thinking"`
+	Signature        string                             `json:"signature"`
+	ThoughtSignature string                             `json:"thought_signature"` // Used for google
+	ResponsesData    *openai.ResponsesReasoningMetadata `json:"responses_data"`
+	StartedAt        int64                              `json:"started_at,omitempty"`
+	FinishedAt       int64                              `json:"finished_at,omitempty"`
 }
 
 func (tc ReasoningContent) String() string {
@@ -85,11 +93,11 @@ func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
 func (BinaryContent) isPart() {}
 
 type ToolCall struct {
-	ID       string `json:"id"`
-	Name     string `json:"name"`
-	Input    string `json:"input"`
-	Type     string `json:"type"`
-	Finished bool   `json:"finished"`
+	ID               string `json:"id"`
+	Name             string `json:"name"`
+	Input            string `json:"input"`
+	ProviderExecuted bool   `json:"provider_executed"`
+	Finished         bool   `json:"finished"`
 }
 
 func (ToolCall) isPart() {}
@@ -98,6 +106,8 @@ type ToolResult struct {
 	ToolCallID string `json:"tool_call_id"`
 	Name       string `json:"name"`
 	Content    string `json:"content"`
+	Data       string `json:"data"`
+	MIMEType   string `json:"mime_type"`
 	Metadata   string `json:"metadata"`
 	IsError    bool   `json:"is_error"`
 }
@@ -114,14 +124,15 @@ type Finish struct {
 func (Finish) isPart() {}
 
 type Message struct {
-	ID        string
-	Role      MessageRole
-	SessionID string
-	Parts     []ContentPart
-	Model     string
-	Provider  string
-	CreatedAt int64
-	UpdatedAt int64
+	ID               string
+	Role             MessageRole
+	SessionID        string
+	Parts            []ContentPart
+	Model            string
+	Provider         string
+	CreatedAt        int64
+	UpdatedAt        int64
+	IsSummaryMessage bool
 }
 
 func (m *Message) Content() TextContent {
@@ -250,6 +261,22 @@ func (m *Message) AppendReasoningContent(delta string) {
 	}
 }
 
+func (m *Message) AppendThoughtSignature(signature string) {
+	for i, part := range m.Parts {
+		if c, ok := part.(ReasoningContent); ok {
+			m.Parts[i] = ReasoningContent{
+				Thinking:         c.Thinking,
+				ThoughtSignature: c.ThoughtSignature + signature,
+				Signature:        c.Signature,
+				StartedAt:        c.StartedAt,
+				FinishedAt:       c.FinishedAt,
+			}
+			return
+		}
+	}
+	m.Parts = append(m.Parts, ReasoningContent{ThoughtSignature: signature})
+}
+
 func (m *Message) AppendReasoningSignature(signature string) {
 	for i, part := range m.Parts {
 		if c, ok := part.(ReasoningContent); ok {
@@ -265,6 +292,20 @@ func (m *Message) AppendReasoningSignature(signature string) {
 	m.Parts = append(m.Parts, ReasoningContent{Signature: signature})
 }
 
+func (m *Message) SetReasoningResponsesData(data *openai.ResponsesReasoningMetadata) {
+	for i, part := range m.Parts {
+		if c, ok := part.(ReasoningContent); ok {
+			m.Parts[i] = ReasoningContent{
+				Thinking:      c.Thinking,
+				ResponsesData: data,
+				StartedAt:     c.StartedAt,
+				FinishedAt:    c.FinishedAt,
+			}
+			return
+		}
+	}
+}
+
 func (m *Message) FinishThinking() {
 	for i, part := range m.Parts {
 		if c, ok := part.(ReasoningContent); ok {
@@ -303,7 +344,6 @@ func (m *Message) FinishToolCall(toolCallID string) {
 					ID:       c.ID,
 					Name:     c.Name,
 					Input:    c.Input,
-					Type:     c.Type,
 					Finished: true,
 				}
 				return
@@ -320,7 +360,6 @@ func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) {
 					ID:       c.ID,
 					Name:     c.Name,
 					Input:    c.Input + inputDelta,
-					Type:     c.Type,
 					Finished: c.Finished,
 				}
 				return
@@ -384,3 +423,90 @@ func (m *Message) AddImageURL(url, detail string) {
 func (m *Message) AddBinary(mimeType string, data []byte) {
 	m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
 }
+
+func (m *Message) ToAIMessage() []fantasy.Message {
+	var messages []fantasy.Message
+	switch m.Role {
+	case User:
+		var parts []fantasy.MessagePart
+		text := strings.TrimSpace(m.Content().Text)
+		if text != "" {
+			parts = append(parts, fantasy.TextPart{Text: text})
+		}
+		for _, content := range m.BinaryContent() {
+			parts = append(parts, fantasy.FilePart{
+				Filename:  content.Path,
+				Data:      content.Data,
+				MediaType: content.MIMEType,
+			})
+		}
+		messages = append(messages, fantasy.Message{
+			Role:    fantasy.MessageRoleUser,
+			Content: parts,
+		})
+	case Assistant:
+		var parts []fantasy.MessagePart
+		text := strings.TrimSpace(m.Content().Text)
+		if text != "" {
+			parts = append(parts, fantasy.TextPart{Text: text})
+		}
+		reasoning := m.ReasoningContent()
+		if reasoning.Thinking != "" {
+			reasoningPart := fantasy.ReasoningPart{Text: reasoning.Thinking, ProviderOptions: fantasy.ProviderOptions{}}
+			if reasoning.Signature != "" {
+				reasoningPart.ProviderOptions[anthropic.Name] = &anthropic.ReasoningOptionMetadata{
+					Signature: reasoning.Signature,
+				}
+			}
+			if reasoning.ResponsesData != nil {
+				reasoningPart.ProviderOptions[openai.Name] = reasoning.ResponsesData
+			}
+			if reasoning.ThoughtSignature != "" {
+				reasoningPart.ProviderOptions[google.Name] = &google.ReasoningMetadata{
+					Signature: reasoning.ThoughtSignature,
+				}
+			}
+			parts = append(parts, reasoningPart)
+		}
+		for _, call := range m.ToolCalls() {
+			parts = append(parts, fantasy.ToolCallPart{
+				ToolCallID:       call.ID,
+				ToolName:         call.Name,
+				Input:            call.Input,
+				ProviderExecuted: call.ProviderExecuted,
+			})
+		}
+		messages = append(messages, fantasy.Message{
+			Role:    fantasy.MessageRoleAssistant,
+			Content: parts,
+		})
+	case Tool:
+		var parts []fantasy.MessagePart
+		for _, result := range m.ToolResults() {
+			var content fantasy.ToolResultOutputContent
+			if result.IsError {
+				content = fantasy.ToolResultOutputContentError{
+					Error: errors.New(result.Content),
+				}
+			} else if result.Data != "" {
+				content = fantasy.ToolResultOutputContentMedia{
+					Data:      result.Data,
+					MediaType: result.MIMEType,
+				}
+			} else {
+				content = fantasy.ToolResultOutputContentText{
+					Text: result.Content,
+				}
+			}
+			parts = append(parts, fantasy.ToolResultPart{
+				ToolCallID: result.ToolCallID,
+				Output:     content,
+			})
+		}
+		messages = append(messages, fantasy.Message{
+			Role:    fantasy.MessageRoleTool,
+			Content: parts,
+		})
+	}
+	return messages
+}
diff --git a/internal/message/message.go b/internal/message/message.go
index 7cd823bc..663a8a3e 100644
--- a/internal/message/message.go
+++ b/internal/message/message.go
@@ -13,10 +13,11 @@ import (
 )
 
 type CreateMessageParams struct {
-	Role     MessageRole
-	Parts    []ContentPart
-	Model    string
-	Provider string
+	Role             MessageRole
+	Parts            []ContentPart
+	Model            string
+	Provider         string
+	IsSummaryMessage bool
 }
 
 type Service interface {
@@ -64,13 +65,18 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
 	if err != nil {
 		return Message{}, err
 	}
+	isSummary := int64(0)
+	if params.IsSummaryMessage {
+		isSummary = 1
+	}
 	dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
-		ID:        uuid.New().String(),
-		SessionID: sessionID,
-		Role:      string(params.Role),
-		Parts:     string(partsJSON),
-		Model:     sql.NullString{String: string(params.Model), Valid: true},
-		Provider:  sql.NullString{String: params.Provider, Valid: params.Provider != ""},
+		ID:               uuid.New().String(),
+		SessionID:        sessionID,
+		Role:             string(params.Role),
+		Parts:            string(partsJSON),
+		Model:            sql.NullString{String: string(params.Model), Valid: true},
+		Provider:         sql.NullString{String: params.Provider, Valid: params.Provider != ""},
+		IsSummaryMessage: isSummary,
 	})
 	if err != nil {
 		return Message{}, err
@@ -151,14 +157,15 @@ func (s *service) fromDBItem(item db.Message) (Message, error) {
 		return Message{}, err
 	}
 	return Message{
-		ID:        item.ID,
-		SessionID: item.SessionID,
-		Role:      MessageRole(item.Role),
-		Parts:     parts,
-		Model:     item.Model.String,
-		Provider:  item.Provider.String,
-		CreatedAt: item.CreatedAt,
-		UpdatedAt: item.UpdatedAt,
+		ID:               item.ID,
+		SessionID:        item.SessionID,
+		Role:             MessageRole(item.Role),
+		Parts:            parts,
+		Model:            item.Model.String,
+		Provider:         item.Provider.String,
+		CreatedAt:        item.CreatedAt,
+		UpdatedAt:        item.UpdatedAt,
+		IsSummaryMessage: item.IsSummaryMessage != 0,
 	}, nil
 }
 
diff --git a/internal/permission/permission.go b/internal/permission/permission.go
index 77b2526a..e7bc3f65 100644
--- a/internal/permission/permission.go
+++ b/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"`
diff --git a/internal/session/session.go b/internal/session/session.go
index f83f66ff..a1818970 100644
--- a/internal/session/session.go
+++ b/internal/session/session.go
@@ -3,6 +3,8 @@ package session
 import (
 	"context"
 	"database/sql"
+	"fmt"
+	"strings"
 
 	"github.com/charmbracelet/crush/internal/db"
 	"github.com/charmbracelet/crush/internal/event"
@@ -32,6 +34,11 @@ type Service interface {
 	List(ctx context.Context) ([]Session, error)
 	Save(ctx context.Context, session Session) (Session, error)
 	Delete(ctx context.Context, id string) error
+
+	// Agent tool session management
+	CreateAgentToolSessionID(messageID, toolCallID string) string
+	ParseAgentToolSessionID(sessionID string) (messageID string, toolCallID string, ok bool)
+	IsAgentToolSession(sessionID string) bool
 }
 
 type service struct {
@@ -157,3 +164,23 @@ func NewService(q db.Querier) Service {
 		q,
 	}
 }
+
+// CreateAgentToolSessionID creates a session ID for agent tool sessions using the format "messageID$$toolCallID"
+func (s *service) CreateAgentToolSessionID(messageID, toolCallID string) string {
+	return fmt.Sprintf("%s$$%s", messageID, toolCallID)
+}
+
+// ParseAgentToolSessionID parses an agent tool session ID into its components
+func (s *service) ParseAgentToolSessionID(sessionID string) (messageID string, toolCallID string, ok bool) {
+	parts := strings.Split(sessionID, "$$")
+	if len(parts) != 2 {
+		return "", "", false
+	}
+	return parts[0], parts[1], true
+}
+
+// IsAgentToolSession checks if a session ID follows the agent tool session format
+func (s *service) IsAgentToolSession(sessionID string) bool {
+	_, _, ok := s.ParseAgentToolSessionID(sessionID)
+	return ok
+}
diff --git a/internal/shell/coreutils.go b/internal/shell/coreutils.go
new file mode 100644
index 00000000..ad5401d5
--- /dev/null
+++ b/internal/shell/coreutils.go
@@ -0,0 +1,19 @@
+package shell
+
+import (
+	"os"
+	"runtime"
+	"strconv"
+)
+
+var useGoCoreUtils bool
+
+func init() {
+	// If CRUSH_CORE_UTILS is set to either true or false, respect that.
+	// By default, enable on Windows only.
+	if v, err := strconv.ParseBool(os.Getenv("CRUSH_CORE_UTILS")); err == nil {
+		useGoCoreUtils = v
+	} else {
+		useGoCoreUtils = runtime.GOOS == "windows"
+	}
+}
diff --git a/internal/shell/persistent.go b/internal/shell/persistent.go
index 90c93339..5f6fd455 100644
--- a/internal/shell/persistent.go
+++ b/internal/shell/persistent.go
@@ -29,6 +29,12 @@ func GetPersistentShell(cwd string) *PersistentShell {
 	return shellInstance
 }
 
+// INFO: only used for tests
+func Reset(cwd string) {
+	once = sync.Once{}
+	_ = GetPersistentShell(cwd)
+}
+
 // slog.dapter adapts the internal slog.package to the Logger interface
 type loggingAdapter struct{}
 
diff --git a/internal/shell/shell.go b/internal/shell/shell.go
index 5a10be95..7a4b19b8 100644
--- a/internal/shell/shell.go
+++ b/internal/shell/shell.go
@@ -100,7 +100,7 @@ func (s *Shell) Exec(ctx context.Context, command string) (string, string, error
 	s.mu.Lock()
 	defer s.mu.Unlock()
 
-	return s.execPOSIX(ctx, command)
+	return s.exec(ctx, command)
 }
 
 // GetWorkingDir returns the current working directory
@@ -228,8 +228,8 @@ func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHand
 	}
 }
 
-// execPOSIX executes commands using POSIX shell emulation (cross-platform)
-func (s *Shell) execPOSIX(ctx context.Context, command string) (string, string, error) {
+// exec executes commands using a cross-platform shell interpreter.
+func (s *Shell) exec(ctx context.Context, command string) (string, string, error) {
 	line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
 	if err != nil {
 		return "", "", fmt.Errorf("could not parse command: %w", err)
@@ -241,7 +241,7 @@ func (s *Shell) execPOSIX(ctx context.Context, command string) (string, string,
 		interp.Interactive(false),
 		interp.Env(expand.ListEnviron(s.env...)),
 		interp.Dir(s.cwd),
-		interp.ExecHandlers(s.blockHandler(), coreutils.ExecHandler),
+		interp.ExecHandlers(s.execHandlers()...),
 	)
 	if err != nil {
 		return "", "", fmt.Errorf("could not run command: %w", err)
@@ -249,14 +249,23 @@ func (s *Shell) execPOSIX(ctx context.Context, command string) (string, string,
 
 	err = runner.Run(ctx, line)
 	s.cwd = runner.Dir
-	s.env = []string{}
 	for name, vr := range runner.Vars {
 		s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
 	}
-	s.logger.InfoPersist("POSIX command finished", "command", command, "err", err)
+	s.logger.InfoPersist("command finished", "command", command, "err", err)
 	return stdout.String(), stderr.String(), err
 }
 
+func (s *Shell) execHandlers() []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
+	handlers := []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc{
+		s.blockHandler(),
+	}
+	if useGoCoreUtils {
+		handlers = append(handlers, coreutils.ExecHandler)
+	}
+	return handlers
+}
+
 // IsInterrupt checks if an error is due to interruption
 func IsInterrupt(err error) bool {
 	return errors.Is(err, context.Canceled) ||
diff --git a/internal/term/term.go b/internal/term/term.go
new file mode 100644
index 00000000..8f86ea24
--- /dev/null
+++ b/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")
+}
diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go
index 05ac4da9..1ffa8074 100644
--- a/internal/tui/components/anim/anim.go
+++ b/internal/tui/components/anim/anim.go
@@ -11,11 +11,12 @@ 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"
+	"github.com/charmbracelet/crush/internal/tui/util"
 )
 
 const (
@@ -318,7 +319,7 @@ func (a *Anim) Init() tea.Cmd {
 }
 
 // Update processes animation steps (or not).
-func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (a *Anim) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case StepMsg:
 		if msg.id != a.id {
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index 8688f7e2..6d7660dc 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -5,11 +5,12 @@ 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/llm/agent"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
@@ -101,10 +102,10 @@ func (m *messageListCmp) Init() tea.Cmd {
 }
 
 // Update handles incoming messages and updates the component state.
-func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
-	if m.session.ID != "" && m.app.CoderAgent != nil {
-		queueSize := m.app.CoderAgent.QueuedPrompts(m.session.ID)
+	if m.session.ID != "" && m.app.AgentCoordinator != nil {
+		queueSize := m.app.AgentCoordinator.QueuedPrompts(m.session.ID)
 		if queueSize != m.promptQueue {
 			m.promptQueue = queueSize
 			cmds = append(cmds, m.SetSize(m.width, m.height))
@@ -235,7 +236,7 @@ func (m *messageListCmp) View() string {
 				m.listCmp.View(),
 			),
 	}
-	if m.app.CoderAgent != nil && m.promptQueue > 0 {
+	if m.app.AgentCoordinator != nil && m.promptQueue > 0 {
 		queuePill := queuePill(m.promptQueue, t)
 		view = append(view, t.S().Base.PaddingLeft(4).PaddingTop(1).Render(queuePill))
 	}
@@ -261,12 +262,19 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
 	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
 		return nil
 	}
+
+	// Check if this is an agent tool session and parse it
+	childSessionID := event.Payload.SessionID
+	parentMessageID, toolCallID, ok := m.app.Sessions.ParseAgentToolSessionID(childSessionID)
+	if !ok {
+		return nil
+	}
 	items := m.listCmp.Items()
 	toolCallInx := NotFound
 	var toolCall messages.ToolCallCmp
 	for i := len(items) - 1; i >= 0; i-- {
 		if msg, ok := items[i].(messages.ToolCallCmp); ok {
-			if msg.GetToolCall().ID == event.Payload.SessionID {
+			if msg.ParentMessageID() == parentMessageID && msg.GetToolCall().ID == toolCallID {
 				toolCallInx = i
 				toolCall = msg
 			}
@@ -327,6 +335,11 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
 			return nil
 		}
 		return m.handleNewMessage(event.Payload)
+	case pubsub.DeletedEvent:
+		if event.Payload.SessionID != m.session.ID {
+			return nil
+		}
+		return m.handleDeleteMessage(event.Payload)
 	case pubsub.UpdatedEvent:
 		if event.Payload.SessionID != m.session.ID {
 			return m.handleChildSession(event)
@@ -353,6 +366,18 @@ func (m *messageListCmp) messageExists(messageID string) bool {
 	return false
 }
 
+// handleDeleteMessage removes a message from the list.
+func (m *messageListCmp) handleDeleteMessage(msg message.Message) tea.Cmd {
+	items := m.listCmp.Items()
+	for i := len(items) - 1; i >= 0; i-- {
+		if msgCmp, ok := items[i].(messages.MessageCmp); ok && msgCmp.GetMessage().ID == msg.ID {
+			m.listCmp.DeleteItem(items[i].ID())
+			return nil
+		}
+	}
+	return nil
+}
+
 // handleNewMessage routes new messages to appropriate handlers based on role.
 func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
 	switch msg.Role {
@@ -611,9 +636,10 @@ 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 {
-			nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
+		// 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)
 			nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
 			nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index f70a0a3d..1ce0a634 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/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 {
@@ -86,6 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
 
 const (
 	maxAttachments = 5
+	maxFileResults = 25
 )
 
 type OpenEditorMsg struct {
@@ -171,7 +172,7 @@ func (m *editorCmp) repositionCompletions() tea.Msg {
 	return completions.RepositionCompletionsMsg{X: x, Y: y}
 }
 
-func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
@@ -211,7 +212,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 	case commands.OpenExternalEditorMsg:
-		if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
+		if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
 			return m, util.ReportWarn("Agent is working, please wait...")
 		}
 		return m, m.openEditor(m.textarea.Value())
@@ -219,7 +220,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.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 {
@@ -263,8 +264,13 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.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
@@ -297,7 +303,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 		if key.Matches(msg, m.keyMap.OpenEditor) {
-			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
+			if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
 				return m, util.ReportWarn("Agent is working, please wait...")
 			}
 			return m, m.openEditor(m.textarea.Value())
@@ -336,7 +342,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.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:]
@@ -415,7 +421,7 @@ func (m *editorCmp) randomizePlaceholders() {
 func (m *editorCmp) View() string {
 	t := styles.CurrentTheme()
 	// Update placeholder
-	if m.app.CoderAgent != nil && m.app.CoderAgent.IsBusy() {
+	if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
 		m.textarea.Placeholder = m.workingPlaceholder
 	} else {
 		m.textarea.Placeholder = m.readyPlaceholder
@@ -500,6 +506,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
 		Completions: completionItems,
 		X:           x,
 		Y:           y,
+		MaxResults:  maxFileResults,
 	}
 }
 
diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go
index 8bc8b235..0ba45718 100644
--- a/internal/tui/components/chat/editor/keys.go
+++ b/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 {
diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go
index 21861a4a..59389815 100644
--- a/internal/tui/components/chat/header/header.go
+++ b/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"
 )
@@ -44,7 +44,7 @@ func (h *header) Init() tea.Cmd {
 	return nil
 }
 
-func (h *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (h *header) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case pubsub.Event[session.Session]:
 		if msg.Type == pubsub.UpdatedEvent {
@@ -119,7 +119,7 @@ func (h *header) details(availWidth int) string {
 		parts = append(parts, s.Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
 	}
 
-	agentCfg := config.Get().Agents["coder"]
+	agentCfg := config.Get().Agents[config.AgentCoder]
 	model := config.Get().GetModelByType(agentCfg.Model)
 	percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
 	formattedPercentage := s.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go
index d931ba7e..54f36fbb 100644
--- a/internal/tui/components/chat/messages/messages.go
+++ b/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"
@@ -35,7 +35,7 @@ var ClearSelectionKey = key.NewBinding(key.WithKeys("esc", "alt+esc"), key.WithH
 // MessageCmp defines the interface for message components in the chat interface.
 // It combines standard UI model interfaces with message-specific functionality.
 type MessageCmp interface {
-	util.Model                      // Basic Bubble Tea model interface
+	util.Model                      // Basic Bubble util.Model interface
 	layout.Sizeable                 // Width/height management
 	layout.Focusable                // Focus state management
 	GetMessage() message.Message    // Access to underlying message data
@@ -94,7 +94,7 @@ func (m *messageCmp) Init() tea.Cmd {
 
 // Update handles incoming messages and updates the component state.
 // Manages animation updates for spinning messages and stops animation when appropriate.
-func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *messageCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case anim.StepMsg:
 		m.spinning = m.shouldSpin()
@@ -122,6 +122,9 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 // Returns different views for spinning, user, and assistant messages.
 func (m *messageCmp) View() string {
 	if m.spinning && m.message.ReasoningContent().Thinking == "" {
+		if m.message.IsSummaryMessage {
+			m.anim.SetLabel("Summarizing")
+		}
 		return m.style().PaddingLeft(1).Render(m.anim.View())
 	}
 	if m.message.ID != "" {
@@ -184,7 +187,7 @@ func (m *messageCmp) renderAssistantMessage() string {
 	finishedData := m.message.FinishPart()
 	thinkingContent := ""
 
-	if thinking || m.message.ReasoningContent().Thinking != "" {
+	if thinking || strings.TrimSpace(m.message.ReasoningContent().Thinking) != "" {
 		m.anim.SetLabel("Thinking")
 		thinkingContent = m.renderThinkingContent()
 	} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
@@ -256,22 +259,32 @@ func (m *messageCmp) toMarkdown(content string) string {
 func (m *messageCmp) renderThinkingContent() string {
 	t := styles.CurrentTheme()
 	reasoningContent := m.message.ReasoningContent()
-	if reasoningContent.Thinking == "" {
+	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())
@@ -296,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
 }
 
@@ -310,7 +324,7 @@ func (m *messageCmp) shouldSpin() bool {
 		return false
 	}
 
-	if m.message.Content().Text != "" {
+	if strings.TrimSpace(m.message.Content().Text) != "" {
 		return false
 	}
 	if len(m.message.ToolCalls()) > 0 {
@@ -384,7 +398,7 @@ func (m *assistantSectionModel) Init() tea.Cmd {
 	return nil
 }
 
-func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (m *assistantSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
 	return m, nil
 }
 
diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go
index ffe3301c..a13be29f 100644
--- a/internal/tui/components/chat/messages/renderer.go
+++ b/internal/tui/components/chat/messages/renderer.go
@@ -6,15 +6,15 @@ 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"
 	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/llm/agent"
-	"github.com/charmbracelet/crush/internal/llm/tools"
 	"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{} })
@@ -364,6 +366,17 @@ func (mer multiEditRenderer) Render(v *toolCallCmp) string {
 				Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
 			formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
 		}
+
+		// Add failed edits warning if any exist
+		if len(meta.EditsFailed) > 0 {
+			noteTag := t.S().Base.Padding(0, 2).Background(t.Info).Foreground(t.White).Render("Note")
+			noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, len(params.Edits))
+			note := t.S().Base.
+				Width(v.textWidth() - 2).
+				Render(fmt.Sprintf("%s %s", noteTag, t.S().Muted.Render(noteMsg)))
+			formatted = lipgloss.JoinVertical(lipgloss.Left, formatted, "", note)
+		}
+
 		return formatted
 	})
 }
@@ -396,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 {
@@ -420,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"
@@ -431,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 {
@@ -439,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
 // -----------------------------------------------------------------------------
@@ -598,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
@@ -618,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,
@@ -635,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 == "" {
@@ -657,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)
 }
 
@@ -673,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:]
@@ -697,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)
 	}
@@ -708,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, "…"))
 }
 
@@ -725,7 +837,7 @@ func earlyState(header string, v *toolCallCmp) (string, bool) {
 		message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
 	case v.result.ToolCallID == "":
 		if v.permissionRequested && !v.permissionGranted {
-			message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting for permission...")
+			message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting permission...")
 		} else {
 			message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...")
 		}
@@ -753,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)
 		}
@@ -780,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
@@ -874,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:
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index 7e03674f..123d02e6 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/internal/tui/components/chat/messages/tool.go
@@ -7,27 +7,27 @@ 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"
 	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/llm/agent"
-	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"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"
 )
 
 // ToolCallCmp defines the interface for tool call components in the chat interface.
 // It manages the display of tool execution including pending states, results, and errors.
 type ToolCallCmp interface {
-	util.Model                         // Basic Bubble Tea model interface
+	util.Model                         // Basic Bubble util.Model interface
 	layout.Sizeable                    // Width/height management
 	layout.Focusable                   // Focus state management
 	GetToolCall() message.ToolCall     // Access to tool call data
@@ -147,7 +147,7 @@ func (m *toolCallCmp) Init() tea.Cmd {
 
 // Update handles incoming messages and updates the component state.
 // Manages animation updates for pending tool calls.
-func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *toolCallCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case anim.StepMsg:
 		var cmds []tea.Cmd
@@ -160,7 +160,7 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		if m.spinning {
 			u, cmd := m.anim.Update(msg)
-			m.anim = u.(util.Model)
+			m.anim = u
 			cmds = append(cmds, cmd)
 		}
 		return m, tea.Batch(cmds...)
@@ -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
 }
diff --git a/internal/tui/components/chat/queue.go b/internal/tui/components/chat/queue.go
index 7bbb2c6f..f35700e2 100644
--- a/internal/tui/components/chat/queue.go
+++ b/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 {
diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go
index b50a78c7..9b3d52da 100644
--- a/internal/tui/components/chat/sidebar/sidebar.go
+++ b/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"
 )
@@ -88,7 +88,7 @@ func (m *sidebarCmp) Init() tea.Cmd {
 	return nil
 }
 
-func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *sidebarCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case SessionFilesMsg:
 		m.files = csync.NewMap[string, SessionFile]()
@@ -545,7 +545,7 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
 
 func (s *sidebarCmp) currentModelBlock() string {
 	cfg := config.Get()
-	agentCfg := cfg.Agents["coder"]
+	agentCfg := cfg.Agents[config.AgentCoder]
 
 	selectedModel := cfg.Models[agentCfg.Model]
 
@@ -563,13 +563,6 @@ func (s *sidebarCmp) currentModelBlock() string {
 	if model.CanReason {
 		reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
 		switch modelProvider.Type {
-		case catwalk.TypeOpenAI:
-			reasoningEffort := model.DefaultReasoningEffort
-			if selectedModel.ReasoningEffort != "" {
-				reasoningEffort = selectedModel.ReasoningEffort
-			}
-			formatter := cases.Title(language.English, cases.NoLower)
-			parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
 		case catwalk.TypeAnthropic:
 			formatter := cases.Title(language.English, cases.NoLower)
 			if selectedModel.Think {
@@ -577,6 +570,13 @@ func (s *sidebarCmp) currentModelBlock() string {
 			} else {
 				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
 			}
+		default:
+			reasoningEffort := model.DefaultReasoningEffort
+			if selectedModel.ReasoningEffort != "" {
+				reasoningEffort = selectedModel.ReasoningEffort
+			}
+			formatter := cases.Title(language.English, cases.NoLower)
+			parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
 		}
 	}
 	if s.session.ID != "" {
diff --git a/internal/tui/components/chat/splash/keys.go b/internal/tui/components/chat/splash/keys.go
index d36c8d8e..5bd9e333 100644
--- a/internal/tui/components/chat/splash/keys.go
+++ b/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 {
diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go
index 187fc35e..3b995ab2 100644
--- a/internal/tui/components/chat/splash/splash.go
+++ b/internal/tui/components/chat/splash/splash.go
@@ -5,13 +5,14 @@ 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"
 	"github.com/charmbracelet/crush/internal/home"
-	"github.com/charmbracelet/crush/internal/llm/prompt"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
@@ -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 {
@@ -135,7 +135,7 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
 }
 
 // Update implements SplashPage.
-func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		return s, s.SetSize(msg.Width, msg.Height)
@@ -334,7 +334,7 @@ func (s *splashCmp) initializeProject() tea.Cmd {
 		cmds = append(cmds,
 			util.CmdHandler(chat.SessionClearedMsg{}),
 			util.CmdHandler(chat.SendMsg{
-				Text: prompt.Initialize(),
+				Text: agent.InitializePrompt(),
 			}),
 		)
 	}
@@ -695,7 +695,7 @@ func (s *splashCmp) mcpBlock() string {
 
 func (s *splashCmp) currentModelBlock() string {
 	cfg := config.Get()
-	agentCfg := cfg.Agents["coder"]
+	agentCfg := cfg.Agents[config.AgentCoder]
 	model := config.Get().GetModelByType(agentCfg.Model)
 	if model == nil {
 		return ""
diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go
index ae3c233e..31532952 100644
--- a/internal/tui/components/completions/completions.go
+++ b/internal/tui/components/completions/completions.go
@@ -3,12 +3,12 @@ package completions
 import (
 	"strings"
 
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
 const maxCompletionsHeight = 10
@@ -22,6 +22,7 @@ type OpenCompletionsMsg struct {
 	Completions []Completion
 	X           int // X position for the completions popup
 	Y           int // Y position for the completions popup
+	MaxResults  int // Maximum number of results to render, 0 for no limit
 }
 
 type FilterCompletionsMsg struct {
@@ -111,7 +112,7 @@ func (c *completionsCmp) Init() tea.Cmd {
 }
 
 // Update implements Completions.
-func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *completionsCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		c.wWidth, c.wHeight = msg.Width, msg.Height
@@ -192,6 +193,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		c.width = width
 		c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
+		c.list.SetResultsSize(msg.MaxResults)
 		return c, tea.Batch(
 			c.list.SetItems(items),
 			c.list.SetSize(c.width, c.height),
diff --git a/internal/tui/components/completions/keys.go b/internal/tui/components/completions/keys.go
index dec1059f..7adaaa02 100644
--- a/internal/tui/components/completions/keys.go
+++ b/internal/tui/components/completions/keys.go
@@ -1,7 +1,7 @@
 package completions
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 type KeyMap struct {
diff --git a/internal/tui/components/core/core.go b/internal/tui/components/core/core.go
index 80c28ba1..2b60664c 100644
--- a/internal/tui/components/core/core.go
+++ b/internal/tui/components/core/core.go
@@ -4,12 +4,12 @@ import (
 	"image/color"
 	"strings"
 
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/lipgloss/v2"
 	"github.com/alecthomas/chroma/v2"
-	"github.com/charmbracelet/bubbles/v2/help"
-	"github.com/charmbracelet/bubbles/v2/key"
 	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
 	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 )
 
diff --git a/internal/tui/components/core/layout/layout.go b/internal/tui/components/core/layout/layout.go
index 6ceb30ad..99358755 100644
--- a/internal/tui/components/core/layout/layout.go
+++ b/internal/tui/components/core/layout/layout.go
@@ -1,8 +1,8 @@
 package layout
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
 )
 
 // TODO: move this to core
diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go
index b01873a2..db51c8f0 100644
--- a/internal/tui/components/core/status/status.go
+++ b/internal/tui/components/core/status/status.go
@@ -3,11 +3,11 @@ package status
 import (
 	"time"
 
-	"github.com/charmbracelet/bubbles/v2/help"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/help"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 )
 
@@ -36,7 +36,7 @@ func (m *statusCmp) Init() tea.Cmd {
 	return nil
 }
 
-func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *statusCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go
index 72677bc9..a4c852cd 100644
--- a/internal/tui/components/dialogs/commands/arguments.go
+++ b/internal/tui/components/dialogs/commands/arguments.go
@@ -1,17 +1,16 @@
 package commands
 
 import (
-	"fmt"
-	"strings"
+	"cmp"
 
-	"github.com/charmbracelet/bubbles/v2/help"
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/textinput"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
 const (
@@ -20,9 +19,10 @@ const (
 
 // ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
 type ShowArgumentsDialogMsg struct {
-	CommandID string
-	Content   string
-	ArgNames  []string
+	CommandID   string
+	Description string
+	ArgNames    []string
+	OnSubmit    func(args map[string]string) tea.Cmd
 }
 
 // CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
@@ -39,26 +39,39 @@ type CommandArgumentsDialog interface {
 }
 
 type commandArgumentsDialogCmp struct {
-	width   int
-	wWidth  int // Width of the terminal window
-	wHeight int // Height of the terminal window
-
-	inputs     []textinput.Model
-	focusIndex int
-	keys       ArgumentsDialogKeyMap
-	commandID  string
-	content    string
-	argNames   []string
-	help       help.Model
+	wWidth, wHeight int
+	width, height   int
+
+	inputs    []textinput.Model
+	focused   int
+	keys      ArgumentsDialogKeyMap
+	arguments []Argument
+	help      help.Model
+
+	id          string
+	title       string
+	name        string
+	description string
+
+	onSubmit func(args map[string]string) tea.Cmd
+}
+
+type Argument struct {
+	Name, Title, Description string
+	Required                 bool
 }
 
-func NewCommandArgumentsDialog(commandID, content string, argNames []string) CommandArgumentsDialog {
+func NewCommandArgumentsDialog(
+	id, title, name, description string,
+	arguments []Argument,
+	onSubmit func(args map[string]string) tea.Cmd,
+) CommandArgumentsDialog {
 	t := styles.CurrentTheme()
-	inputs := make([]textinput.Model, len(argNames))
+	inputs := make([]textinput.Model, len(arguments))
 
-	for i, name := range argNames {
+	for i, arg := range arguments {
 		ti := textinput.New()
-		ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
+		ti.Placeholder = cmp.Or(arg.Description, "Enter value for "+arg.Title)
 		ti.SetWidth(40)
 		ti.SetVirtualCursor(false)
 		ti.Prompt = ""
@@ -75,14 +88,16 @@ func NewCommandArgumentsDialog(commandID, content string, argNames []string) Com
 	}
 
 	return &commandArgumentsDialogCmp{
-		inputs:     inputs,
-		keys:       DefaultArgumentsDialogKeyMap(),
-		commandID:  commandID,
-		content:    content,
-		argNames:   argNames,
-		focusIndex: 0,
-		width:      60,
-		help:       help.New(),
+		inputs:      inputs,
+		keys:        DefaultArgumentsDialogKeyMap(),
+		id:          id,
+		name:        name,
+		title:       title,
+		description: description,
+		arguments:   arguments,
+		width:       60,
+		help:        help.New(),
+		onSubmit:    onSubmit,
 	}
 }
 
@@ -92,52 +107,56 @@ func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
 }
 
 // Update implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width
 		c.wHeight = msg.Height
+		c.width = min(90, c.wWidth)
+		c.height = min(15, c.wHeight)
+		for i := range c.inputs {
+			c.inputs[i].SetWidth(c.width - (paddingHorizontal * 2))
+		}
 	case tea.KeyPressMsg:
 		switch {
+		case key.Matches(msg, c.keys.Close):
+			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
 		case key.Matches(msg, c.keys.Confirm):
-			if c.focusIndex == len(c.inputs)-1 {
-				content := c.content
-				for i, name := range c.argNames {
+			if c.focused == len(c.inputs)-1 {
+				args := make(map[string]string)
+				for i, arg := range c.arguments {
 					value := c.inputs[i].Value()
-					placeholder := "$" + name
-					content = strings.ReplaceAll(content, placeholder, value)
+					args[arg.Name] = value
 				}
 				return c, tea.Sequence(
 					util.CmdHandler(dialogs.CloseDialogMsg{}),
-					util.CmdHandler(CommandRunCustomMsg{
-						Content: content,
-					}),
+					c.onSubmit(args),
 				)
 			}
 			// Otherwise, move to the next input
-			c.inputs[c.focusIndex].Blur()
-			c.focusIndex++
-			c.inputs[c.focusIndex].Focus()
+			c.inputs[c.focused].Blur()
+			c.focused++
+			c.inputs[c.focused].Focus()
 		case key.Matches(msg, c.keys.Next):
 			// Move to the next input
-			c.inputs[c.focusIndex].Blur()
-			c.focusIndex = (c.focusIndex + 1) % len(c.inputs)
-			c.inputs[c.focusIndex].Focus()
+			c.inputs[c.focused].Blur()
+			c.focused = (c.focused + 1) % len(c.inputs)
+			c.inputs[c.focused].Focus()
 		case key.Matches(msg, c.keys.Previous):
 			// Move to the previous input
-			c.inputs[c.focusIndex].Blur()
-			c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
-			c.inputs[c.focusIndex].Focus()
+			c.inputs[c.focused].Blur()
+			c.focused = (c.focused - 1 + len(c.inputs)) % len(c.inputs)
+			c.inputs[c.focused].Focus()
 		case key.Matches(msg, c.keys.Close):
 			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
 		default:
 			var cmd tea.Cmd
-			c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
+			c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg)
 			return c, cmd
 		}
 	case tea.PasteMsg:
 		var cmd tea.Cmd
-		c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
+		c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg)
 		return c, cmd
 	}
 	return c, nil
@@ -152,26 +171,28 @@ func (c *commandArgumentsDialogCmp) View() string {
 		Foreground(t.Primary).
 		Bold(true).
 		Padding(0, 1).
-		Render("Command Arguments")
+		Render(cmp.Or(c.title, c.name))
 
-	explanation := t.S().Text.
+	promptName := t.S().Text.
 		Padding(0, 1).
-		Render("This command requires arguments.")
+		Render(c.description)
 
-	// Create input fields for each argument
 	inputFields := make([]string, len(c.inputs))
 	for i, input := range c.inputs {
-		// Highlight the label of the focused input
-		labelStyle := baseStyle.
-			Padding(1, 1, 0, 1)
+		labelStyle := baseStyle.Padding(1, 1, 0, 1)
 
-		if i == c.focusIndex {
+		if i == c.focused {
 			labelStyle = labelStyle.Foreground(t.FgBase).Bold(true)
 		} else {
 			labelStyle = labelStyle.Foreground(t.FgMuted)
 		}
 
-		label := labelStyle.Render(c.argNames[i] + ":")
+		arg := c.arguments[i]
+		argName := cmp.Or(arg.Title, arg.Name)
+		if arg.Required {
+			argName += "*"
+		}
+		label := labelStyle.Render(argName + ":")
 
 		field := t.S().Text.
 			Padding(0, 1).
@@ -180,18 +201,14 @@ func (c *commandArgumentsDialogCmp) View() string {
 		inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
 	}
 
-	// Join all elements vertically
-	elements := []string{title, explanation}
+	elements := []string{title, promptName}
 	elements = append(elements, inputFields...)
 
 	c.help.ShowAll = false
 	helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys))
 	elements = append(elements, "", helpText)
 
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		elements...,
-	)
+	content := lipgloss.JoinVertical(lipgloss.Left, elements...)
 
 	return baseStyle.Padding(1, 1, 0, 1).
 		Border(lipgloss.RoundedBorder()).
@@ -201,26 +218,33 @@ func (c *commandArgumentsDialogCmp) View() string {
 }
 
 func (c *commandArgumentsDialogCmp) Cursor() *tea.Cursor {
-	cursor := c.inputs[c.focusIndex].Cursor()
+	if len(c.inputs) == 0 {
+		return nil
+	}
+	cursor := c.inputs[c.focused].Cursor()
 	if cursor != nil {
 		cursor = c.moveCursor(cursor)
 	}
 	return cursor
 }
 
+const (
+	headerHeight      = 3
+	itemHeight        = 3
+	paddingHorizontal = 3
+)
+
 func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
 	row, col := c.Position()
-	offset := row + 3 + (1+c.focusIndex)*3
+	offset := row + headerHeight + (1+c.focused)*itemHeight
 	cursor.Y += offset
-	cursor.X = cursor.X + col + 3
+	cursor.X = cursor.X + col + paddingHorizontal
 	return cursor
 }
 
 func (c *commandArgumentsDialogCmp) Position() (int, int) {
-	row := c.wHeight / 2
-	row -= c.wHeight / 2
-	col := c.wWidth / 2
-	col -= c.width / 2
+	row := (c.wHeight / 2) - (c.height / 2)
+	col := (c.wWidth / 2) - (c.width / 2)
 	return row, col
 }
 
diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index 664158fc..137b20a8 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -2,15 +2,20 @@ package commands
 
 import (
 	"os"
+	"slices"
+	"strings"
 
-	"github.com/charmbracelet/bubbles/v2/help"
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/lipgloss/v2"
 
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/prompt"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -25,9 +30,14 @@ const (
 	defaultWidth int = 70
 )
 
+type commandType uint
+
+func (c commandType) String() string { return []string{"System", "User", "MCP"}[c] }
+
 const (
-	SystemCommands int = iota
+	SystemCommands commandType = iota
 	UserCommands
+	MCPPrompts
 )
 
 type listModel = list.FilterableList[list.CompletionItem[Command]]
@@ -54,9 +64,10 @@ type commandDialogCmp struct {
 	commandList  listModel
 	keyMap       CommandsDialogKeyMap
 	help         help.Model
-	commandType  int       // SystemCommands or UserCommands
-	userCommands []Command // User-defined commands
-	sessionID    string    // Current session ID
+	selected     commandType           // Selected SystemCommands, UserCommands, or MCPPrompts
+	userCommands []Command             // User-defined commands
+	mcpPrompts   *csync.Slice[Command] // MCP prompts
+	sessionID    string                // Current session ID
 }
 
 type (
@@ -102,8 +113,9 @@ func NewCommandDialog(sessionID string) CommandsDialog {
 		width:       defaultWidth,
 		keyMap:      DefaultCommandsDialogKeyMap(),
 		help:        help,
-		commandType: SystemCommands,
+		selected:    SystemCommands,
 		sessionID:   sessionID,
+		mcpPrompts:  csync.NewSlice[Command](),
 	}
 }
 
@@ -113,18 +125,29 @@ func (c *commandDialogCmp) Init() tea.Cmd {
 		return util.ReportError(err)
 	}
 	c.userCommands = commands
-	return c.SetCommandType(c.commandType)
+	c.mcpPrompts.SetSlice(loadMCPPrompts())
+	return c.setCommandType(c.selected)
 }
 
-func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width
 		c.wHeight = msg.Height
 		return c, tea.Batch(
-			c.SetCommandType(c.commandType),
+			c.setCommandType(c.selected),
 			c.commandList.SetSize(c.listWidth(), c.listHeight()),
 		)
+	case pubsub.Event[mcp.Event]:
+		// Reload MCP prompts when MCP state changes
+		if msg.Type == pubsub.UpdatedEvent {
+			c.mcpPrompts.SetSlice(loadMCPPrompts())
+			// If we're currently viewing MCP prompts, refresh the list
+			if c.selected == MCPPrompts {
+				return c, c.setCommandType(MCPPrompts)
+			}
+			return c, nil
+		}
 	case tea.KeyPressMsg:
 		switch {
 		case key.Matches(msg, c.keyMap.Select):
@@ -138,15 +161,10 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				command.Handler(command),
 			)
 		case key.Matches(msg, c.keyMap.Tab):
-			if len(c.userCommands) == 0 {
+			if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
 				return c, nil
 			}
-			// Toggle command type between System and User commands
-			if c.commandType == SystemCommands {
-				return c, c.SetCommandType(UserCommands)
-			} else {
-				return c, c.SetCommandType(SystemCommands)
-			}
+			return c, c.setCommandType(c.next())
 		case key.Matches(msg, c.keyMap.Close):
 			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
 		default:
@@ -158,13 +176,35 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return c, nil
 }
 
+func (c *commandDialogCmp) next() commandType {
+	switch c.selected {
+	case SystemCommands:
+		if len(c.userCommands) > 0 {
+			return UserCommands
+		}
+		if c.mcpPrompts.Len() > 0 {
+			return MCPPrompts
+		}
+		fallthrough
+	case UserCommands:
+		if c.mcpPrompts.Len() > 0 {
+			return MCPPrompts
+		}
+		fallthrough
+	case MCPPrompts:
+		return SystemCommands
+	default:
+		return SystemCommands
+	}
+}
+
 func (c *commandDialogCmp) View() string {
 	t := styles.CurrentTheme()
 	listView := c.commandList
 	radio := c.commandTypeRadio()
 
 	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
-	if len(c.userCommands) == 0 {
+	if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
 		header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
 	}
 	content := lipgloss.JoinVertical(
@@ -190,27 +230,41 @@ func (c *commandDialogCmp) Cursor() *tea.Cursor {
 
 func (c *commandDialogCmp) commandTypeRadio() string {
 	t := styles.CurrentTheme()
-	choices := []string{"System", "User"}
-	iconSelected := "◉"
-	iconUnselected := "○"
-	if c.commandType == SystemCommands {
-		return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
+
+	fn := func(i commandType) string {
+		if i == c.selected {
+			return "◉ " + i.String()
+		}
+		return "○ " + i.String()
+	}
+
+	parts := []string{
+		fn(SystemCommands),
+	}
+	if len(c.userCommands) > 0 {
+		parts = append(parts, fn(UserCommands))
+	}
+	if c.mcpPrompts.Len() > 0 {
+		parts = append(parts, fn(MCPPrompts))
 	}
-	return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
+	return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
 }
 
 func (c *commandDialogCmp) listWidth() int {
 	return defaultWidth - 2 // 4 for padding
 }
 
-func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
-	c.commandType = commandType
+func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd {
+	c.selected = commandType
 
 	var commands []Command
-	if c.commandType == SystemCommands {
+	switch c.selected {
+	case SystemCommands:
 		commands = c.defaultCommands()
-	} else {
+	case UserCommands:
 		commands = c.userCommands
+	case MCPPrompts:
+		commands = slices.Collect(c.mcpPrompts.Seq())
 	}
 
 	commandItems := []list.CompletionItem[Command]{}
@@ -303,7 +357,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 
 	// Add reasoning toggle for models that support it
 	cfg := config.Get()
-	if agentCfg, ok := cfg.Agents["coder"]; ok {
+	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
 		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
 		model := cfg.GetModelByType(agentCfg.Model)
 		if providerCfg != nil && model != nil && model.CanReason {
@@ -326,7 +380,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 			}
 
 			// OpenAI models: reasoning effort dialog
-			if providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
+			if len(model.ReasoningLevels) > 0 {
 				commands = append(commands, Command{
 					ID:          "select_reasoning_effort",
 					Title:       "Select Reasoning Effort",
@@ -350,7 +404,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 		})
 	}
 	if c.sessionID != "" {
-		agentCfg := config.Get().Agents["coder"]
+		agentCfg := config.Get().Agents[config.AgentCoder]
 		model := config.Get().GetModelByType(agentCfg.Model)
 		if model.SupportsImages {
 			commands = append(commands, Command{
@@ -402,7 +456,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 			Description: "Create/Update the CRUSH.md memory file",
 			Handler: func(cmd Command) tea.Cmd {
 				return util.CmdHandler(chat.SendMsg{
-					Text: prompt.Initialize(),
+					Text: agent.InitializePrompt(),
 				})
 			},
 		},
diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go
index 65d4af84..f07f1c5f 100644
--- a/internal/tui/components/dialogs/commands/keys.go
+++ b/internal/tui/components/dialogs/commands/keys.go
@@ -1,7 +1,7 @@
 package commands
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 type CommandsDialogKeyMap struct {
diff --git a/internal/tui/components/dialogs/commands/loader.go b/internal/tui/components/dialogs/commands/loader.go
index 74d9c7e4..95875a68 100644
--- a/internal/tui/components/dialogs/commands/loader.go
+++ b/internal/tui/components/dialogs/commands/loader.go
@@ -1,6 +1,8 @@
 package commands
 
 import (
+	"cmp"
+	"context"
 	"fmt"
 	"io/fs"
 	"os"
@@ -8,15 +10,17 @@ import (
 	"regexp"
 	"strings"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
 
 const (
-	UserCommandPrefix    = "user:"
-	ProjectCommandPrefix = "project:"
+	userCommandPrefix    = "user:"
+	projectCommandPrefix = "project:"
 )
 
 var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
@@ -50,7 +54,7 @@ func buildCommandSources(cfg *config.Config) []commandSource {
 	if dir := getXDGCommandsDir(); dir != "" {
 		sources = append(sources, commandSource{
 			path:   dir,
-			prefix: UserCommandPrefix,
+			prefix: userCommandPrefix,
 		})
 	}
 
@@ -58,14 +62,14 @@ func buildCommandSources(cfg *config.Config) []commandSource {
 	if home := home.Dir(); home != "" {
 		sources = append(sources, commandSource{
 			path:   filepath.Join(home, ".crush", "commands"),
-			prefix: UserCommandPrefix,
+			prefix: userCommandPrefix,
 		})
 	}
 
 	// Project directory
 	sources = append(sources, commandSource{
 		path:   filepath.Join(cfg.Options.DataDirectory, "commands"),
-		prefix: ProjectCommandPrefix,
+		prefix: projectCommandPrefix,
 	})
 
 	return sources
@@ -127,12 +131,13 @@ func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, erro
 	}
 
 	id := buildCommandID(path, baseDir, prefix)
+	desc := fmt.Sprintf("Custom command from %s", filepath.Base(path))
 
 	return Command{
 		ID:          id,
 		Title:       id,
-		Description: fmt.Sprintf("Custom command from %s", filepath.Base(path)),
-		Handler:     createCommandHandler(id, string(content)),
+		Description: desc,
+		Handler:     createCommandHandler(id, desc, string(content)),
 	}, nil
 }
 
@@ -149,21 +154,35 @@ func buildCommandID(path, baseDir, prefix string) string {
 	return prefix + strings.Join(parts, ":")
 }
 
-func createCommandHandler(id string, content string) func(Command) tea.Cmd {
+func createCommandHandler(id, desc, content string) func(Command) tea.Cmd {
 	return func(cmd Command) tea.Cmd {
 		args := extractArgNames(content)
 
-		if len(args) > 0 {
-			return util.CmdHandler(ShowArgumentsDialogMsg{
-				CommandID: id,
-				Content:   content,
-				ArgNames:  args,
+		if len(args) == 0 {
+			return util.CmdHandler(CommandRunCustomMsg{
+				Content: content,
 			})
 		}
+		return util.CmdHandler(ShowArgumentsDialogMsg{
+			CommandID:   id,
+			Description: desc,
+			ArgNames:    args,
+			OnSubmit: func(args map[string]string) tea.Cmd {
+				return execUserPrompt(content, args)
+			},
+		})
+	}
+}
 
-		return util.CmdHandler(CommandRunCustomMsg{
+func execUserPrompt(content string, args map[string]string) tea.Cmd {
+	return func() tea.Msg {
+		for name, value := range args {
+			placeholder := "$" + name
+			content = strings.ReplaceAll(content, placeholder, value)
+		}
+		return CommandRunCustomMsg{
 			Content: content,
-		})
+		}
 	}
 }
 
@@ -201,3 +220,53 @@ func isMarkdownFile(name string) bool {
 type CommandRunCustomMsg struct {
 	Content string
 }
+
+func loadMCPPrompts() []Command {
+	var commands []Command
+	for mcpName, prompts := range mcp.Prompts() {
+		for _, prompt := range prompts {
+			key := mcpName + ":" + prompt.Name
+			commands = append(commands, Command{
+				ID:          key,
+				Title:       cmp.Or(prompt.Title, prompt.Name),
+				Description: prompt.Description,
+				Handler:     createMCPPromptHandler(mcpName, prompt.Name, prompt),
+			})
+		}
+	}
+
+	return commands
+}
+
+func createMCPPromptHandler(mcpName, promptName string, prompt *mcp.Prompt) func(Command) tea.Cmd {
+	return func(cmd Command) tea.Cmd {
+		if len(prompt.Arguments) == 0 {
+			return execMCPPrompt(mcpName, promptName, nil)
+		}
+		return util.CmdHandler(ShowMCPPromptArgumentsDialogMsg{
+			Prompt: prompt,
+			OnSubmit: func(args map[string]string) tea.Cmd {
+				return execMCPPrompt(mcpName, promptName, args)
+			},
+		})
+	}
+}
+
+func execMCPPrompt(clientName, promptName string, args map[string]string) tea.Cmd {
+	return func() tea.Msg {
+		ctx := context.Background()
+		result, err := mcp.GetPromptMessages(ctx, clientName, promptName, args)
+		if err != nil {
+			return util.ReportError(err)
+		}
+
+		return chat.SendMsg{
+			Text: strings.Join(result, " "),
+		}
+	}
+}
+
+type ShowMCPPromptArgumentsDialogMsg struct {
+	Prompt   *mcp.Prompt
+	OnSubmit func(arg map[string]string) tea.Cmd
+}
diff --git a/internal/tui/components/dialogs/compact/compact.go b/internal/tui/components/dialogs/compact/compact.go
deleted file mode 100644
index ecde402f..00000000
--- a/internal/tui/components/dialogs/compact/compact.go
+++ /dev/null
@@ -1,272 +0,0 @@
-package compact
-
-import (
-	"context"
-
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
-
-	"github.com/charmbracelet/crush/internal/llm/agent"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const CompactDialogID dialogs.DialogID = "compact"
-
-// CompactDialog interface for the session compact dialog
-type CompactDialog interface {
-	dialogs.DialogModel
-}
-
-type compactDialogCmp struct {
-	wWidth, wHeight int
-	width, height   int
-	selected        int
-	keyMap          KeyMap
-	sessionID       string
-	state           compactState
-	progress        string
-	agent           agent.Service
-	noAsk           bool // If true, skip confirmation dialog
-}
-
-type compactState int
-
-const (
-	stateConfirm compactState = iota
-	stateCompacting
-	stateError
-)
-
-// NewCompactDialogCmp creates a new session compact dialog
-func NewCompactDialogCmp(agent agent.Service, sessionID string, noAsk bool) CompactDialog {
-	return &compactDialogCmp{
-		sessionID: sessionID,
-		keyMap:    DefaultKeyMap(),
-		state:     stateConfirm,
-		selected:  0,
-		agent:     agent,
-		noAsk:     noAsk,
-	}
-}
-
-func (c *compactDialogCmp) Init() tea.Cmd {
-	if c.noAsk {
-		// If noAsk is true, skip confirmation and start compaction immediately
-		return c.startCompaction()
-	}
-	return nil
-}
-
-func (c *compactDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		c.wWidth = msg.Width
-		c.wHeight = msg.Height
-		cmd := c.SetSize()
-		return c, cmd
-
-	case tea.KeyPressMsg:
-		switch c.state {
-		case stateConfirm:
-			switch {
-			case key.Matches(msg, c.keyMap.ChangeSelection):
-				c.selected = (c.selected + 1) % 2
-				return c, nil
-			case key.Matches(msg, c.keyMap.Select):
-				if c.selected == 0 {
-					return c, c.startCompaction()
-				} else {
-					return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-				}
-			case key.Matches(msg, c.keyMap.Y):
-				return c, c.startCompaction()
-			case key.Matches(msg, c.keyMap.N):
-				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-			case key.Matches(msg, c.keyMap.Close):
-				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-			}
-		case stateCompacting:
-			switch {
-			case key.Matches(msg, c.keyMap.Close):
-				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-			}
-		case stateError:
-			switch {
-			case key.Matches(msg, c.keyMap.Select):
-				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-			case key.Matches(msg, c.keyMap.Close):
-				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-			}
-		}
-
-	case agent.AgentEvent:
-		switch msg.Type {
-		case agent.AgentEventTypeSummarize:
-			if msg.Error != nil {
-				c.state = stateError
-				c.progress = "Error: " + msg.Error.Error()
-			} else if msg.Done {
-				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-			} else {
-				c.progress = msg.Progress
-			}
-		case agent.AgentEventTypeError:
-			// Handle errors that occur during summarization but are sent as separate error events.
-			c.state = stateError
-			if msg.Error != nil {
-				c.progress = "Error: " + msg.Error.Error()
-			} else {
-				c.progress = "An unknown error occurred"
-			}
-		}
-		return c, nil
-	}
-
-	return c, nil
-}
-
-func (c *compactDialogCmp) startCompaction() tea.Cmd {
-	c.state = stateCompacting
-	c.progress = "Starting summarization..."
-	return func() tea.Msg {
-		err := c.agent.Summarize(context.Background(), c.sessionID)
-		if err != nil {
-			c.state = stateError
-			c.progress = "Error: " + err.Error()
-		}
-		return nil
-	}
-}
-
-func (c *compactDialogCmp) renderButtons() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-
-	buttons := []core.ButtonOpts{
-		{
-			Text:           "Yes",
-			UnderlineIndex: 0, // "Y"
-			Selected:       c.selected == 0,
-		},
-		{
-			Text:           "No",
-			UnderlineIndex: 0, // "N"
-			Selected:       c.selected == 1,
-		},
-	}
-
-	content := core.SelectableButtons(buttons, "  ")
-
-	return baseStyle.AlignHorizontal(lipgloss.Right).Width(c.width - 4).Render(content)
-}
-
-func (c *compactDialogCmp) renderContent() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-
-	switch c.state {
-	case stateConfirm:
-		explanation := t.S().Text.
-			Width(c.width - 4).
-			Render("This will summarize the current session and reset the context. The conversation history will be condensed into a summary to free up context space while preserving important information.")
-
-		question := t.S().Text.
-			Width(c.width - 4).
-			Render("Do you want to continue?")
-
-		return baseStyle.Render(lipgloss.JoinVertical(
-			lipgloss.Left,
-			explanation,
-			"",
-			question,
-		))
-	case stateCompacting:
-		return baseStyle.Render(lipgloss.JoinVertical(
-			lipgloss.Left,
-			c.progress,
-			"",
-			"Please wait...",
-		))
-	case stateError:
-		return baseStyle.Render(lipgloss.JoinVertical(
-			lipgloss.Left,
-			c.progress,
-			"",
-			"Press Enter to close",
-		))
-	}
-	return ""
-}
-
-func (c *compactDialogCmp) render() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-
-	var title string
-	switch c.state {
-	case stateConfirm:
-		title = "Compact Session"
-	case stateCompacting:
-		title = "Compacting Session"
-	case stateError:
-		title = "Compact Failed"
-	}
-
-	titleView := core.Title(title, c.width-4)
-	content := c.renderContent()
-
-	var dialogContent string
-	if c.state == stateConfirm {
-		buttons := c.renderButtons()
-		dialogContent = lipgloss.JoinVertical(
-			lipgloss.Top,
-			titleView,
-			"",
-			content,
-			"",
-			buttons,
-			"",
-		)
-	} else {
-		dialogContent = lipgloss.JoinVertical(
-			lipgloss.Top,
-			titleView,
-			"",
-			content,
-			"",
-		)
-	}
-
-	return baseStyle.
-		Padding(0, 1).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus).
-		Width(c.width).
-		Render(dialogContent)
-}
-
-func (c *compactDialogCmp) View() string {
-	return c.render()
-}
-
-// SetSize sets the size of the component.
-func (c *compactDialogCmp) SetSize() tea.Cmd {
-	c.width = min(90, c.wWidth)
-	c.height = min(15, c.wHeight)
-	return nil
-}
-
-func (c *compactDialogCmp) Position() (int, int) {
-	row := (c.wHeight / 2) - (c.height / 2)
-	col := (c.wWidth / 2) - (c.width / 2)
-	return row, col
-}
-
-// ID implements CompactDialog.
-func (c *compactDialogCmp) ID() dialogs.DialogID {
-	return CompactDialogID
-}
diff --git a/internal/tui/components/dialogs/compact/keys.go b/internal/tui/components/dialogs/compact/keys.go
deleted file mode 100644
index cec14864..00000000
--- a/internal/tui/components/dialogs/compact/keys.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package compact
-
-import (
-	"github.com/charmbracelet/bubbles/v2/key"
-)
-
-// KeyMap defines the key bindings for the compact dialog.
-type KeyMap struct {
-	ChangeSelection key.Binding
-	Select          key.Binding
-	Y               key.Binding
-	N               key.Binding
-	Close           key.Binding
-}
-
-// DefaultKeyMap returns the default key bindings for the compact dialog.
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		ChangeSelection: key.NewBinding(
-			key.WithKeys("tab", "left", "right", "h", "l"),
-			key.WithHelp("tab/←/→", "toggle selection"),
-		),
-		Select: key.NewBinding(
-			key.WithKeys("enter"),
-			key.WithHelp("enter", "confirm"),
-		),
-		Y: key.NewBinding(
-			key.WithKeys("y"),
-			key.WithHelp("y", "yes"),
-		),
-		N: key.NewBinding(
-			key.WithKeys("n"),
-			key.WithHelp("n", "no"),
-		),
-		Close: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "cancel"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.ChangeSelection,
-		k.Select,
-		k.Y,
-		k.N,
-		k.Close,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		k.ChangeSelection,
-		k.Select,
-		k.Close,
-	}
-}
diff --git a/internal/tui/components/dialogs/dialogs.go b/internal/tui/components/dialogs/dialogs.go
index 99e14e51..4dacd56d 100644
--- a/internal/tui/components/dialogs/dialogs.go
+++ b/internal/tui/components/dialogs/dialogs.go
@@ -3,9 +3,9 @@ package dialogs
 import (
 	"slices"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
 type DialogID string
@@ -32,7 +32,7 @@ type CloseDialogMsg struct{}
 
 // DialogCmp manages a stack of dialogs with keyboard navigation.
 type DialogCmp interface {
-	tea.Model
+	util.Model
 
 	Dialogs() []DialogModel
 	HasDialogs() bool
@@ -62,7 +62,7 @@ func (d dialogCmp) Init() tea.Cmd {
 }
 
 // Update handles dialog lifecycle and forwards messages to the active dialog.
-func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (d dialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		var cmds []tea.Cmd
@@ -98,7 +98,11 @@ func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return d, nil
 }
 
-func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
+func (d dialogCmp) View() string {
+	return ""
+}
+
+func (d dialogCmp) handleOpen(msg OpenDialogMsg) (util.Model, tea.Cmd) {
 	if d.HasDialogs() {
 		dialog := d.dialogs[len(d.dialogs)-1]
 		if dialog.ID() == msg.Model.ID() {
diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go
index fcec2fc8..fd9f85e7 100644
--- a/internal/tui/components/dialogs/filepicker/filepicker.go
+++ b/internal/tui/components/dialogs/filepicker/filepicker.go
@@ -7,10 +7,11 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/charmbracelet/bubbles/v2/filepicker"
-	"github.com/charmbracelet/bubbles/v2/help"
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/filepicker"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
@@ -18,7 +19,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/image"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
 const (
@@ -88,7 +88,7 @@ func (m *model) Init() tea.Cmd {
 	return m.filePicker.Init()
 }
 
-func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *model) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.wWidth = msg.Width
diff --git a/internal/tui/components/dialogs/filepicker/keys.go b/internal/tui/components/dialogs/filepicker/keys.go
index 72e32f2a..1fc493ba 100644
--- a/internal/tui/components/dialogs/filepicker/keys.go
+++ b/internal/tui/components/dialogs/filepicker/keys.go
@@ -1,7 +1,7 @@
 package filepicker
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 // KeyMap defines keyboard bindings for dialog management.
diff --git a/internal/tui/components/dialogs/keys.go b/internal/tui/components/dialogs/keys.go
index 264ce3d4..178ea656 100644
--- a/internal/tui/components/dialogs/keys.go
+++ b/internal/tui/components/dialogs/keys.go
@@ -1,7 +1,7 @@
 package dialogs
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 // KeyMap defines keyboard bindings for dialog management.
diff --git a/internal/tui/components/dialogs/models/apikey.go b/internal/tui/components/dialogs/models/apikey.go
index 0490335f..6ab890ca 100644
--- a/internal/tui/components/dialogs/models/apikey.go
+++ b/internal/tui/components/dialogs/models/apikey.go
@@ -3,13 +3,14 @@ package models
 import (
 	"fmt"
 
-	"github.com/charmbracelet/bubbles/v2/spinner"
-	"github.com/charmbracelet/bubbles/v2/textinput"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/spinner"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/tui/util"
 )
 
 type APIKeyInputState int
@@ -75,7 +76,7 @@ func (a *APIKeyInput) Init() tea.Cmd {
 	return a.spinner.Tick
 }
 
-func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (a *APIKeyInput) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case spinner.TickMsg:
 		if a.state == APIKeyInputStateVerifying {
diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go
index 4ec1a487..e36a18d7 100644
--- a/internal/tui/components/dialogs/models/keys.go
+++ b/internal/tui/components/dialogs/models/keys.go
@@ -1,7 +1,7 @@
 package models
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 type KeyMap struct {
diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go
index 77398c4d..87d333c4 100644
--- a/internal/tui/components/dialogs/models/list.go
+++ b/internal/tui/components/dialogs/models/list.go
@@ -1,11 +1,12 @@
 package models
 
 import (
+	"cmp"
 	"fmt"
 	"slices"
 	"strings"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/tui/exp/list"
@@ -151,7 +152,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 					ContextWindow:          model.ContextWindow,
 					DefaultMaxTokens:       model.DefaultMaxTokens,
 					CanReason:              model.CanReason,
-					HasReasoningEffort:     model.HasReasoningEffort,
+					ReasoningLevels:        model.ReasoningLevels,
 					DefaultReasoningEffort: model.DefaultReasoningEffort,
 					SupportsImages:         model.SupportsImages,
 				}
@@ -195,34 +196,59 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 			continue
 		}
 
-		// Check if this provider is configured and not disabled
-		if providerConfig, exists := cfg.Providers.Get(string(provider.ID)); exists && providerConfig.Disable {
+		providerConfig, providerConfigured := cfg.Providers.Get(string(provider.ID))
+		if providerConfigured && providerConfig.Disable {
 			continue
 		}
 
-		name := provider.Name
+		displayProvider := provider
+		if providerConfigured {
+			displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name)
+			modelIndex := make(map[string]int, len(displayProvider.Models))
+			for i, model := range displayProvider.Models {
+				modelIndex[model.ID] = i
+			}
+			for _, model := range providerConfig.Models {
+				if model.ID == "" {
+					continue
+				}
+				if idx, ok := modelIndex[model.ID]; ok {
+					if model.Name != "" {
+						displayProvider.Models[idx].Name = model.Name
+					}
+					continue
+				}
+				if model.Name == "" {
+					model.Name = model.ID
+				}
+				displayProvider.Models = append(displayProvider.Models, model)
+				modelIndex[model.ID] = len(displayProvider.Models) - 1
+			}
+		}
+
+		name := displayProvider.Name
 		if name == "" {
-			name = string(provider.ID)
+			name = string(displayProvider.ID)
 		}
 
 		section := list.NewItemSection(name)
-		if _, ok := cfg.Providers.Get(string(provider.ID)); ok {
+		if providerConfigured {
 			section.SetInfo(configured)
 		}
 		group := list.Group[list.CompletionItem[ModelOption]]{
 			Section: section,
 		}
-		for _, model := range provider.Models {
+		for _, model := range displayProvider.Models {
 			item := list.NewCompletionItem(model.Name, ModelOption{
-				Provider: provider,
+				Provider: displayProvider,
 				Model:    model,
 			},
 				list.WithCompletionID(
-					fmt.Sprintf("%s:%s", provider.ID, model.ID),
+					fmt.Sprintf("%s:%s", displayProvider.ID, model.ID),
 				),
 			)
 			group.Items = append(group.Items, item)
-			if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
+			if model.ID == currentModel.Model && string(displayProvider.ID) == currentModel.Provider {
 				selectedItemID = item.ID()
 			}
 		}
diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go
index 7c286370..46b4df64 100644
--- a/internal/tui/components/dialogs/models/models.go
+++ b/internal/tui/components/dialogs/models/models.go
@@ -4,10 +4,11 @@ import (
 	"fmt"
 	"time"
 
-	"github.com/charmbracelet/bubbles/v2/help"
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/spinner"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/help"
+	"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/config"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
@@ -15,7 +16,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
 const (
@@ -98,7 +98,7 @@ func (m *modelDialogCmp) Init() tea.Cmd {
 	return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
 }
 
-func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.wWidth = msg.Width
diff --git a/internal/tui/components/dialogs/permissions/keys.go b/internal/tui/components/dialogs/permissions/keys.go
index fc1810fc..5e7786ec 100644
--- a/internal/tui/components/dialogs/permissions/keys.go
+++ b/internal/tui/components/dialogs/permissions/keys.go
@@ -1,7 +1,7 @@
 package permissions
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 type KeyMap struct {
diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go
index 9e0a6b05..b2e52c20 100644
--- a/internal/tui/components/dialogs/permissions/permissions.go
+++ b/internal/tui/components/dialogs/permissions/permissions.go
@@ -5,18 +5,18 @@ import (
 	"fmt"
 	"strings"
 
-	"github.com/charmbracelet/bubbles/v2/help"
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/viewport"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/viewport"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 )
 
@@ -95,7 +95,7 @@ func (p *permissionDialogCmp) supportsDiffView() bool {
 	return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName || p.permission.ToolName == tools.MultiEditToolName
 }
 
-func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 
 	switch msg := msg.(type) {
@@ -373,6 +373,8 @@ func (p *permissionDialogCmp) renderHeader() string {
 		)
 	case tools.FetchToolName:
 		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
+	case tools.AgenticFetchToolName:
+		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
 	case tools.ViewToolName:
 		params := p.permission.Params.(tools.ViewPermissionsParams)
 		fileKey := t.S().Muted.Render("File")
@@ -427,6 +429,8 @@ func (p *permissionDialogCmp) getOrGenerateContent() string {
 		content = p.generateMultiEditContent()
 	case tools.FetchToolName:
 		content = p.generateFetchContent()
+	case tools.AgenticFetchToolName:
+		content = p.generateAgenticFetchContent()
 	case tools.ViewToolName:
 		content = p.generateViewContent()
 	case tools.LSToolName:
@@ -570,6 +574,20 @@ func (p *permissionDialogCmp) generateFetchContent() string {
 	return ""
 }
 
+func (p *permissionDialogCmp) generateAgenticFetchContent() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base.Background(t.BgSubtle)
+	if pr, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams); ok {
+		content := fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt)
+		finalContent := baseStyle.
+			Padding(1, 2).
+			Width(p.contentViewPort.Width()).
+			Render(content)
+		return finalContent
+	}
+	return ""
+}
+
 func (p *permissionDialogCmp) generateViewContent() string {
 	t := styles.CurrentTheme()
 	baseStyle := t.S().Base.Background(t.BgSubtle)
@@ -698,15 +716,14 @@ func (p *permissionDialogCmp) render() string {
 
 	p.contentViewPort.SetWidth(p.width - 4)
 
-	// Get cached or generate content
-	contentFinal := p.getOrGenerateContent()
-
 	// Always set viewport content (the caching is handled in getOrGenerateContent)
 	const minContentHeight = 9
-	contentHeight := min(
-		max(minContentHeight, p.height-minContentHeight),
-		lipgloss.Height(contentFinal),
-	)
+
+	availableDialogHeight := max(minContentHeight, p.height-minContentHeight)
+	p.contentViewPort.SetHeight(availableDialogHeight)
+	contentFinal := p.getOrGenerateContent()
+	contentHeight := min(availableDialogHeight, lipgloss.Height(contentFinal))
+
 	p.contentViewPort.SetHeight(contentHeight)
 	p.contentViewPort.SetContent(contentFinal)
 
@@ -776,6 +793,9 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
 	case tools.FetchToolName:
 		p.width = int(float64(p.wWidth) * 0.8)
 		p.height = int(float64(p.wHeight) * 0.3)
+	case tools.AgenticFetchToolName:
+		p.width = int(float64(p.wWidth) * 0.8)
+		p.height = int(float64(p.wHeight) * 0.4)
 	case tools.ViewToolName:
 		p.width = int(float64(p.wWidth) * 0.8)
 		p.height = int(float64(p.wHeight) * 0.4)
diff --git a/internal/tui/components/dialogs/quit/keys.go b/internal/tui/components/dialogs/quit/keys.go
index 2e8dbc19..15b3e85e 100644
--- a/internal/tui/components/dialogs/quit/keys.go
+++ b/internal/tui/components/dialogs/quit/keys.go
@@ -1,7 +1,7 @@
 package quit
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 // KeyMap defines the keyboard bindings for the quit dialog.
diff --git a/internal/tui/components/dialogs/quit/quit.go b/internal/tui/components/dialogs/quit/quit.go
index 763dc842..4ffc04a0 100644
--- a/internal/tui/components/dialogs/quit/quit.go
+++ b/internal/tui/components/dialogs/quit/quit.go
@@ -1,12 +1,12 @@
 package quit
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
 const (
@@ -40,7 +40,7 @@ func (q *quitDialogCmp) Init() tea.Cmd {
 }
 
 // Update handles keyboard input for the quit dialog.
-func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (q *quitDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		q.wWidth = msg.Width
diff --git a/internal/tui/components/dialogs/reasoning/reasoning.go b/internal/tui/components/dialogs/reasoning/reasoning.go
index ba49abd8..dfe6898b 100644
--- a/internal/tui/components/dialogs/reasoning/reasoning.go
+++ b/internal/tui/components/dialogs/reasoning/reasoning.go
@@ -1,10 +1,12 @@
 package reasoning
 
 import (
-	"github.com/charmbracelet/bubbles/v2/help"
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
 
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
@@ -120,7 +122,7 @@ func (r *reasoningDialogCmp) Init() tea.Cmd {
 
 func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
 	cfg := config.Get()
-	if agentCfg, ok := cfg.Agents["coder"]; ok {
+	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
 		selectedModel := cfg.Models[agentCfg.Model]
 		model := cfg.GetModelByType(agentCfg.Model)
 
@@ -130,19 +132,13 @@ func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
 			currentEffort = model.DefaultReasoningEffort
 		}
 
-		efforts := []EffortOption{
-			{
-				Title:  "Low",
-				Effort: "low",
-			},
-			{
-				Title:  "Medium",
-				Effort: "medium",
-			},
-			{
-				Title:  "High",
-				Effort: "high",
-			},
+		efforts := []EffortOption{}
+		caser := cases.Title(language.Und)
+		for _, level := range model.ReasoningLevels {
+			efforts = append(efforts, EffortOption{
+				Title:  caser.String(level),
+				Effort: level,
+			})
 		}
 
 		effortItems := []list.CompletionItem[EffortOption]{}
@@ -172,7 +168,7 @@ func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
 	return nil
 }
 
-func (r *reasoningDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (r *reasoningDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		r.wWidth = msg.Width
diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go
index 73c50899..94b260bd 100644
--- a/internal/tui/components/dialogs/sessions/keys.go
+++ b/internal/tui/components/dialogs/sessions/keys.go
@@ -1,7 +1,7 @@
 package sessions
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 type KeyMap struct {
diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go
index 037eb5eb..11515eee 100644
--- a/internal/tui/components/dialogs/sessions/sessions.go
+++ b/internal/tui/components/dialogs/sessions/sessions.go
@@ -1,9 +1,10 @@
 package sessions
 
 import (
-	"github.com/charmbracelet/bubbles/v2/help"
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
@@ -12,7 +13,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
 const SessionsDialogID dialogs.DialogID = "sessions"
@@ -81,7 +81,7 @@ func (s *sessionDialogCmp) Init() tea.Cmd {
 	return tea.Sequence(cmds...)
 }
 
-func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *sessionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		var cmds []tea.Cmd
diff --git a/internal/tui/components/files/files.go b/internal/tui/components/files/files.go
index 3e99f222..c7898d47 100644
--- a/internal/tui/components/files/files.go
+++ b/internal/tui/components/files/files.go
@@ -7,7 +7,7 @@ import (
 	"sort"
 	"strings"
 
-	"github.com/charmbracelet/lipgloss/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 
 	"github.com/charmbracelet/crush/internal/config"
diff --git a/internal/tui/components/image/image.go b/internal/tui/components/image/image.go
index 5d84c18e..b526b1bb 100644
--- a/internal/tui/components/image/image.go
+++ b/internal/tui/components/image/image.go
@@ -7,7 +7,7 @@ import (
 	_ "image/jpeg"
 	_ "image/png"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
 )
 
 type Model struct {
diff --git a/internal/tui/components/image/load.go b/internal/tui/components/image/load.go
index 25fb4bc8..1a31bd3d 100644
--- a/internal/tui/components/image/load.go
+++ b/internal/tui/components/image/load.go
@@ -11,7 +11,7 @@ import (
 	"os"
 	"strings"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
 	"github.com/disintegration/imageorient"
 	"github.com/lucasb-eyer/go-colorful"
 	"github.com/muesli/termenv"
diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go
index c5902477..9f4cdfef 100644
--- a/internal/tui/components/logo/logo.go
+++ b/internal/tui/components/logo/logo.go
@@ -4,12 +4,11 @@ package logo
 import (
 	"fmt"
 	"image/color"
-	"math/rand/v2"
 	"strings"
 
+	"charm.land/lipgloss/v2"
 	"github.com/MakeNowJust/heredoc"
 	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/slice"
 )
@@ -53,7 +52,7 @@ func Render(version string, compact bool, o Opts) string {
 	}
 	stretchIndex := -1 // -1 means no stretching.
 	if !compact {
-		stretchIndex = rand.IntN(len(letterforms))
+		stretchIndex = cachedRandN(len(letterforms))
 	}
 
 	crush := renderWord(spacing, stretchIndex, letterforms...)
@@ -337,7 +336,7 @@ func stretchLetterformPart(s string, p letterformProps) string {
 	}
 	n := p.width
 	if p.stretch {
-		n = rand.IntN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
+		n = cachedRandN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
 	}
 	parts := make([]string, n)
 	for i := range parts {
diff --git a/internal/tui/components/logo/rand.go b/internal/tui/components/logo/rand.go
new file mode 100644
index 00000000..cf79487e
--- /dev/null
+++ b/internal/tui/components/logo/rand.go
@@ -0,0 +1,24 @@
+package logo
+
+import (
+	"math/rand/v2"
+	"sync"
+)
+
+var (
+	randCaches   = make(map[int]int)
+	randCachesMu sync.Mutex
+)
+
+func cachedRandN(n int) int {
+	randCachesMu.Lock()
+	defer randCachesMu.Unlock()
+
+	if n, ok := randCaches[n]; ok {
+		return n
+	}
+
+	r := rand.IntN(n)
+	randCaches[n] = r
+	return r
+}
diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go
index 0c0384e9..18c3f74b 100644
--- a/internal/tui/components/lsp/lsp.go
+++ b/internal/tui/components/lsp/lsp.go
@@ -4,13 +4,13 @@ import (
 	"fmt"
 	"strings"
 
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
 
diff --git a/internal/tui/components/mcp/mcp.go b/internal/tui/components/mcp/mcp.go
index fd3bd012..782a776c 100644
--- a/internal/tui/components/mcp/mcp.go
+++ b/internal/tui/components/mcp/mcp.go
@@ -2,11 +2,12 @@ package mcp
 
 import (
 	"fmt"
+	"strings"
 
-	"github.com/charmbracelet/lipgloss/v2"
+	"charm.land/lipgloss/v2"
 
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 )
@@ -40,7 +41,7 @@ func RenderMCPList(opts RenderOptions) []string {
 	}
 
 	// Get MCP states
-	mcpStates := agent.GetMCPStates()
+	mcpStates := mcp.GetStates()
 
 	// Determine how many items to show
 	maxItems := len(mcps)
@@ -56,21 +57,24 @@ func RenderMCPList(opts RenderOptions) []string {
 		// Determine icon and color based on state
 		icon := t.ItemOfflineIcon
 		description := ""
-		extraContent := ""
+		extraContent := []string{}
 
 		if state, exists := mcpStates[l.Name]; exists {
 			switch state.State {
-			case agent.MCPStateDisabled:
+			case mcp.StateDisabled:
 				description = t.S().Subtle.Render("disabled")
-			case agent.MCPStateStarting:
+			case mcp.StateStarting:
 				icon = t.ItemBusyIcon
 				description = t.S().Subtle.Render("starting...")
-			case agent.MCPStateConnected:
+			case mcp.StateConnected:
 				icon = t.ItemOnlineIcon
-				if state.ToolCount > 0 {
-					extraContent = t.S().Subtle.Render(fmt.Sprintf("%d tools", state.ToolCount))
+				if count := state.Counts.Tools; count > 0 {
+					extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d tools", count)))
 				}
-			case agent.MCPStateError:
+				if count := state.Counts.Prompts; count > 0 {
+					extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d prompts", count)))
+				}
+			case mcp.StateError:
 				icon = t.ItemErrorIcon
 				if state.Error != nil {
 					description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
@@ -88,7 +92,7 @@ func RenderMCPList(opts RenderOptions) []string {
 					Icon:         icon.String(),
 					Title:        l.Name,
 					Description:  description,
-					ExtraContent: extraContent,
+					ExtraContent: strings.Join(extraContent, " "),
 				},
 				opts.MaxWidth,
 			),
diff --git a/internal/tui/exp/diffview/chroma.go b/internal/tui/exp/diffview/chroma.go
index 72e286c6..d66cf397 100644
--- a/internal/tui/exp/diffview/chroma.go
+++ b/internal/tui/exp/diffview/chroma.go
@@ -6,9 +6,9 @@ import (
 	"io"
 	"strings"
 
+	"charm.land/lipgloss/v2"
 	"github.com/alecthomas/chroma/v2"
 	"github.com/charmbracelet/crush/internal/ansiext"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
 var _ chroma.Formatter = chromaFormatter{}
diff --git a/internal/tui/exp/diffview/diffview.go b/internal/tui/exp/diffview/diffview.go
index cda4b74b..f1b69bc1 100644
--- a/internal/tui/exp/diffview/diffview.go
+++ b/internal/tui/exp/diffview/diffview.go
@@ -6,10 +6,10 @@ import (
 	"strconv"
 	"strings"
 
+	"charm.land/lipgloss/v2"
 	"github.com/alecthomas/chroma/v2"
 	"github.com/alecthomas/chroma/v2/lexers"
 	"github.com/aymanbagabas/go-udiff"
-	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/zeebo/xxh3"
 )
@@ -500,19 +500,6 @@ outer:
 		}
 	}
 
-	for printedLines < dv.height {
-		if shouldWrite() {
-			ls := dv.style.MissingLine
-			if dv.lineNumbers {
-				b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
-				b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
-			}
-			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render("  "))
-			b.WriteRune('\n')
-		}
-		printedLines++
-	}
-
 	return b.String()
 }
 
@@ -670,22 +657,6 @@ outer:
 		}
 	}
 
-	for printedLines < dv.height {
-		if shouldWrite() {
-			ls := dv.style.MissingLine
-			if dv.lineNumbers {
-				b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
-			}
-			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
-			if dv.lineNumbers {
-				b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
-			}
-			b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
-			b.WriteRune('\n')
-		}
-		printedLines++
-	}
-
 	return b.String()
 }
 
diff --git a/internal/tui/exp/diffview/style.go b/internal/tui/exp/diffview/style.go
index e24f67ec..25fd08ac 100644
--- a/internal/tui/exp/diffview/style.go
+++ b/internal/tui/exp/diffview/style.go
@@ -1,7 +1,7 @@
 package diffview
 
 import (
-	"github.com/charmbracelet/lipgloss/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/x/exp/charmtone"
 )
 
diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go
index e639786d..f7ded77b 100644
--- a/internal/tui/exp/list/filterable.go
+++ b/internal/tui/exp/list/filterable.go
@@ -3,15 +3,14 @@ package list
 import (
 	"regexp"
 	"slices"
-	"sort"
-	"strings"
 
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/textinput"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/sahilm/fuzzy"
 )
 
@@ -28,7 +27,9 @@ type FilterableList[T FilterableItem] interface {
 	Cursor() *tea.Cursor
 	SetInputWidth(int)
 	SetInputPlaceholder(string)
+	SetResultsSize(int)
 	Filter(q string) tea.Cmd
+	fuzzy.Source
 }
 
 type HasMatchIndexes interface {
@@ -47,10 +48,11 @@ type filterableList[T FilterableItem] struct {
 	*filterableOptions
 	width, height int
 	// stores all available items
-	items      []T
-	input      textinput.Model
-	inputWidth int
-	query      string
+	items       []T
+	resultsSize int
+	input       textinput.Model
+	inputWidth  int
+	query       string
 }
 
 type filterableListOption func(*filterableOptions)
@@ -100,7 +102,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
 	f.list = New(items, f.listOptions...).(*list[T])
 
 	f.updateKeyMaps()
-	f.items = slices.Collect(f.list.items.Seq())
+	f.items = f.list.items
 
 	if f.inputHidden {
 		return f
@@ -115,7 +117,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
 	return f
 }
 
-func (f *filterableList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (f *filterableList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
@@ -241,27 +243,23 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
 		}
 	}
 
-	f.selectedItem = ""
+	f.selectedItemIdx = -1
 	if query == "" || len(f.items) == 0 {
 		return f.list.SetItems(f.items)
 	}
 
-	words := make([]string, len(f.items))
-	for i, item := range f.items {
-		words[i] = strings.ToLower(item.FilterValue())
-	}
-
-	matches := fuzzy.Find(query, words)
-
-	sort.SliceStable(matches, func(i, j int) bool {
-		return matches[i].Score > matches[j].Score
-	})
+	matches := fuzzy.FindFrom(query, f)
 
 	var matchedItems []T
-	for _, match := range matches {
+	resultSize := len(matches)
+	if f.resultsSize > 0 && resultSize > f.resultsSize {
+		resultSize = f.resultsSize
+	}
+	for i := range resultSize {
+		match := matches[i]
 		item := f.items[match.Index]
-		if i, ok := any(item).(HasMatchIndexes); ok {
-			i.MatchIndexes(match.MatchedIndexes)
+		if it, ok := any(item).(HasMatchIndexes); ok {
+			it.MatchIndexes(match.MatchedIndexes)
 		}
 		matchedItems = append(matchedItems, item)
 	}
@@ -307,3 +305,15 @@ func (f *filterableList[T]) SetInputWidth(w int) {
 func (f *filterableList[T]) SetInputPlaceholder(ph string) {
 	f.placeholder = ph
 }
+
+func (f *filterableList[T]) SetResultsSize(size int) {
+	f.resultsSize = size
+}
+
+func (f *filterableList[T]) String(i int) string {
+	return f.items[i].FilterValue()
+}
+
+func (f *filterableList[T]) Len() int {
+	return len(f.items)
+}
diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go
index 6e9a5dc7..8597050c 100644
--- a/internal/tui/exp/list/filterable_group.go
+++ b/internal/tui/exp/list/filterable_group.go
@@ -2,16 +2,16 @@ package list
 
 import (
 	"regexp"
-	"slices"
 	"sort"
 	"strings"
 
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/textinput"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/sahilm/fuzzy"
 )
 
@@ -65,7 +65,7 @@ func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filter
 	return f
 }
 
-func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (f *filterableGroupList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
@@ -182,7 +182,7 @@ func (f *filterableGroupList[T]) inputHeight() int {
 
 func (f *filterableGroupList[T]) clearItemState() []tea.Cmd {
 	var cmds []tea.Cmd
-	for _, item := range slices.Collect(f.items.Seq()) {
+	for _, item := range f.items {
 		if i, ok := any(item).(layout.Focusable); ok {
 			cmds = append(cmds, i.Blur())
 		}
@@ -252,7 +252,7 @@ func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string
 
 func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
 	cmds := f.clearItemState()
-	f.selectedItem = ""
+	f.selectedItemIdx = -1
 
 	if query == "" {
 		return f.groupedList.SetGroups(f.groups)
diff --git a/internal/tui/exp/list/grouped.go b/internal/tui/exp/list/grouped.go
index cb54628a..b1408aa6 100644
--- a/internal/tui/exp/list/grouped.go
+++ b/internal/tui/exp/list/grouped.go
@@ -1,10 +1,7 @@
 package list
 
 import (
-	"slices"
-
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/csync"
+	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
@@ -40,9 +37,9 @@ func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T
 			keyMap:    DefaultKeyMap(),
 			focused:   true,
 		},
-		items:         csync.NewSlice[Item](),
-		indexMap:      csync.NewMap[string, int](),
-		renderedItems: csync.NewMap[string, renderedItem](),
+		items:         []Item{},
+		indexMap:      make(map[string]int),
+		renderedItems: make(map[string]renderedItem),
 	}
 	for _, opt := range opts {
 		opt(list.confOptions)
@@ -58,7 +55,7 @@ func (g *groupedList[T]) Init() tea.Cmd {
 	return g.render()
 }
 
-func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (l *groupedList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	u, cmd := l.list.Update(msg)
 	l.list = u.(*list[Item])
 	return l, cmd
@@ -85,13 +82,13 @@ func (g *groupedList[T]) convertItems() {
 			items = append(items, g)
 		}
 	}
-	g.items.SetSlice(items)
+	g.items = items
 }
 
 func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
 	g.groups = groups
 	g.convertItems()
-	return g.SetItems(slices.Collect(g.items.Seq()))
+	return g.SetItems(g.items)
 }
 
 func (g *groupedList[T]) Groups() []Group[T] {
diff --git a/internal/tui/exp/list/items.go b/internal/tui/exp/list/items.go
index 9e7259dc..fa89fb6e 100644
--- a/internal/tui/exp/list/items.go
+++ b/internal/tui/exp/list/items.go
@@ -3,11 +3,12 @@ package list
 import (
 	"image/color"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/google/uuid"
 	"github.com/rivo/uniseg"
@@ -97,7 +98,7 @@ func (c *completionItemCmp[T]) Init() tea.Cmd {
 }
 
 // Update implements CommandItem.
-func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (c *completionItemCmp[T]) Update(tea.Msg) (util.Model, tea.Cmd) {
 	return c, nil
 }
 
@@ -348,7 +349,7 @@ func (m *itemSectionModel) Init() tea.Cmd {
 	return nil
 }
 
-func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (m *itemSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
 	return m, nil
 }
 
diff --git a/internal/tui/exp/list/keys.go b/internal/tui/exp/list/keys.go
index ba0f6cec..e470fbfb 100644
--- a/internal/tui/exp/list/keys.go
+++ b/internal/tui/exp/list/keys.go
@@ -1,7 +1,7 @@
 package list
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 type KeyMap struct {
diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go
index e18b8834..19848719 100644
--- a/internal/tui/exp/list/list.go
+++ b/internal/tui/exp/list/list.go
@@ -1,24 +1,41 @@
 package list
 
 import (
-	"slices"
 	"strings"
 	"sync"
 
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/csync"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"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"
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/ordered"
 	"github.com/rivo/uniseg"
 )
 
+const maxGapSize = 100
+
+var newlineBuffer = strings.Repeat("\n", maxGapSize)
+
+var (
+	specialCharsMap  map[string]struct{}
+	specialCharsOnce sync.Once
+)
+
+func getSpecialCharsMap() map[string]struct{} {
+	specialCharsOnce.Do(func() {
+		specialCharsMap = make(map[string]struct{}, len(styles.SelectionIgnoreIcons))
+		for _, icon := range styles.SelectionIgnoreIcons {
+			specialCharsMap[icon] = struct{}{}
+		}
+	})
+	return specialCharsMap
+}
+
 type Item interface {
 	util.Model
 	layout.Sizeable
@@ -35,7 +52,6 @@ type List[T Item] interface {
 	layout.Sizeable
 	layout.Focusable
 
-	// Just change state
 	MoveUp(int) tea.Cmd
 	MoveDown(int) tea.Cmd
 	GoToTop() tea.Cmd
@@ -69,11 +85,10 @@ const (
 
 const (
 	ItemNotFound              = -1
-	ViewportDefaultScrollSize = 2
+	ViewportDefaultScrollSize = 5
 )
 
 type renderedItem struct {
-	id     string
 	view   string
 	height int
 	start  int
@@ -81,16 +96,16 @@ type renderedItem struct {
 }
 
 type confOptions struct {
-	width, height int
-	gap           int
-	// if you are at the last item and go down it will wrap to the top
-	wrap         bool
-	keyMap       KeyMap
-	direction    direction
-	selectedItem string
-	focused      bool
-	resize       bool
-	enableMouse  bool
+	width, height   int
+	gap             int
+	wrap            bool
+	keyMap          KeyMap
+	direction       direction
+	selectedItemIdx int    // Index of selected item (-1 if none)
+	selectedItemID  string // Temporary storage for WithSelectedItem (resolved in New())
+	focused         bool
+	resize          bool
+	enableMouse     bool
 }
 
 type list[T Item] struct {
@@ -98,19 +113,24 @@ type list[T Item] struct {
 
 	offset int
 
-	indexMap *csync.Map[string, int]
-	items    *csync.Slice[T]
+	indexMap      map[string]int
+	items         []T
+	renderedItems map[string]renderedItem
 
-	renderedItems *csync.Map[string, renderedItem]
+	rendered       string
+	renderedHeight int   // cached height of rendered content
+	lineOffsets    []int // cached byte offsets for each line (for fast slicing)
 
-	renderMu sync.Mutex
-	rendered string
+	cachedView       string
+	cachedViewOffset int
+	cachedViewDirty  bool
 
-	movingByItem       bool
-	selectionStartCol  int
-	selectionStartLine int
-	selectionEndCol    int
-	selectionEndLine   int
+	movingByItem        bool
+	prevSelectedItemIdx int // Index of previously selected item (-1 if none)
+	selectionStartCol   int
+	selectionStartLine  int
+	selectionEndCol     int
+	selectionEndLine    int
 
 	selectionActive bool
 }
@@ -149,7 +169,7 @@ func WithDirectionBackward() ListOption {
 // WithSelectedItem sets the initially selected item in the list.
 func WithSelectedItem(id string) ListOption {
 	return func(l *confOptions) {
-		l.selectedItem = id
+		l.selectedItemID = id // Will be resolved to index in New()
 	}
 }
 
@@ -186,17 +206,19 @@ func WithEnableMouse() ListOption {
 func New[T Item](items []T, opts ...ListOption) List[T] {
 	list := &list[T]{
 		confOptions: &confOptions{
-			direction: DirectionForward,
-			keyMap:    DefaultKeyMap(),
-			focused:   true,
+			direction:       DirectionForward,
+			keyMap:          DefaultKeyMap(),
+			focused:         true,
+			selectedItemIdx: -1,
 		},
-		items:              csync.NewSliceFrom(items),
-		indexMap:           csync.NewMap[string, int](),
-		renderedItems:      csync.NewMap[string, renderedItem](),
-		selectionStartCol:  -1,
-		selectionStartLine: -1,
-		selectionEndLine:   -1,
-		selectionEndCol:    -1,
+		items:               items,
+		indexMap:            make(map[string]int, len(items)),
+		renderedItems:       make(map[string]renderedItem),
+		prevSelectedItemIdx: -1,
+		selectionStartCol:   -1,
+		selectionStartLine:  -1,
+		selectionEndLine:    -1,
+		selectionEndCol:     -1,
 	}
 	for _, opt := range opts {
 		opt(list.confOptions)
@@ -206,8 +228,17 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
 		if i, ok := any(item).(Indexable); ok {
 			i.SetIndex(inx)
 		}
-		list.indexMap.Set(item.ID(), inx)
+		list.indexMap[item.ID()] = inx
+	}
+
+	// Resolve selectedItemID to selectedItemIdx if specified
+	if list.selectedItemID != "" {
+		if idx, ok := list.indexMap[list.selectedItemID]; ok {
+			list.selectedItemIdx = idx
+		}
+		list.selectedItemID = "" // Clear temporary storage
 	}
+
 	return list
 }
 
@@ -217,7 +248,7 @@ func (l *list[T]) Init() tea.Cmd {
 }
 
 // Update implements List.
-func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.MouseWheelMsg:
 		if l.enableMouse {
@@ -225,10 +256,25 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		return l, nil
 	case anim.StepMsg:
+		// Fast path: if no items, skip processing
+		if len(l.items) == 0 {
+			return l, nil
+		}
+
+		// Fast path: check if ANY items are actually spinning before processing
+		if !l.hasSpinningItems() {
+			return l, nil
+		}
+
 		var cmds []tea.Cmd
-		for _, item := range slices.Collect(l.items.Seq()) {
-			if i, ok := any(item).(HasAnim); ok && i.Spinning() {
-				updated, cmd := i.Update(msg)
+		itemsLen := len(l.items)
+		for i := range itemsLen {
+			if i >= len(l.items) {
+				continue
+			}
+			item := l.items[i]
+			if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
+				updated, cmd := animItem.Update(msg)
 				cmds = append(cmds, cmd)
 				if u, ok := updated.(T); ok {
 					cmds = append(cmds, l.UpdateItem(u.ID(), u))
@@ -277,7 +323,7 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return l, nil
 }
 
-func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
+func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	switch msg.Button {
 	case tea.MouseWheelDown:
@@ -288,8 +334,16 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
 	return l, cmd
 }
 
-// selectionView renders the highlighted selection in the view and returns it
-// as a string. If textOnly is true, it won't render any styles.
+func (l *list[T]) hasSpinningItems() bool {
+	for i := range l.items {
+		item := l.items[i]
+		if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
+			return true
+		}
+	}
+	return false
+}
+
 func (l *list[T]) selectionView(view string, textOnly bool) string {
 	t := styles.CurrentTheme()
 	area := uv.Rect(0, 0, l.width, l.height)
@@ -302,10 +356,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
 	}
 	selArea = selArea.Canon()
 
-	specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
-	for _, icon := range styles.SelectionIgnoreIcons {
-		specialChars[icon] = true
-	}
+	specialChars := getSpecialCharsMap()
 
 	isNonWhitespace := func(r rune) bool {
 		return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
@@ -366,7 +417,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
 				}
 
 				char := rune(cellStr[0])
-				isSpecial := specialChars[cellStr]
+				_, isSpecial := specialChars[cellStr]
 
 				if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
 					if bounds.start == -1 {
@@ -409,7 +460,10 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
 			}
 
 			cellStr := cell.String()
-			if len(cellStr) > 0 && !specialChars[cellStr] {
+			if len(cellStr) > 0 {
+				if _, isSpecial := specialChars[cellStr]; isSpecial {
+					continue
+				}
 				if textOnly {
 					// Collect selected text without styles
 					selectedText.WriteString(cell.String())
@@ -439,33 +493,40 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
 	return scr.Render()
 }
 
-// View implements List.
 func (l *list[T]) View() string {
 	if l.height <= 0 || l.width <= 0 {
 		return ""
 	}
+
+	if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
+		return l.cachedView
+	}
+
 	t := styles.CurrentTheme()
-	view := l.rendered
-	lines := strings.Split(view, "\n")
 
 	start, end := l.viewPosition()
 	viewStart := max(0, start)
-	viewEnd := min(len(lines), end+1)
+	viewEnd := end
 
 	if viewStart > viewEnd {
-		viewStart = viewEnd
+		return ""
 	}
-	lines = lines[viewStart:viewEnd]
+
+	view := l.getLines(viewStart, viewEnd)
 
 	if l.resize {
-		return strings.Join(lines, "\n")
+		return view
 	}
+
 	view = t.S().Base.
 		Height(l.height).
 		Width(l.width).
-		Render(strings.Join(lines, "\n"))
+		Render(view)
 
 	if !l.hasSelection() {
+		l.cachedView = view
+		l.cachedViewOffset = l.offset
+		l.cachedViewDirty = false
 		return view
 	}
 
@@ -474,7 +535,7 @@ func (l *list[T]) View() string {
 
 func (l *list[T]) viewPosition() (int, int) {
 	start, end := 0, 0
-	renderedLines := lipgloss.Height(l.rendered) - 1
+	renderedLines := l.renderedHeight - 1
 	if l.direction == DirectionForward {
 		start = max(0, l.offset)
 		end = min(l.offset+l.height-1, renderedLines)
@@ -486,22 +547,114 @@ func (l *list[T]) viewPosition() (int, int) {
 	return start, end
 }
 
+func (l *list[T]) setRendered(rendered string) {
+	l.rendered = rendered
+	l.renderedHeight = lipgloss.Height(rendered)
+	l.cachedViewDirty = true // Mark view cache as dirty
+
+	if len(rendered) > 0 {
+		l.lineOffsets = make([]int, 0, l.renderedHeight)
+		l.lineOffsets = append(l.lineOffsets, 0)
+
+		offset := 0
+		for {
+			idx := strings.IndexByte(rendered[offset:], '\n')
+			if idx == -1 {
+				break
+			}
+			offset += idx + 1
+			l.lineOffsets = append(l.lineOffsets, offset)
+		}
+	} else {
+		l.lineOffsets = nil
+	}
+}
+
+func (l *list[T]) getLines(start, end int) string {
+	if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
+		return ""
+	}
+
+	if end >= len(l.lineOffsets) {
+		end = len(l.lineOffsets) - 1
+	}
+	if start > end {
+		return ""
+	}
+
+	startOffset := l.lineOffsets[start]
+	var endOffset int
+	if end+1 < len(l.lineOffsets) {
+		endOffset = l.lineOffsets[end+1] - 1
+	} else {
+		endOffset = len(l.rendered)
+	}
+
+	if startOffset >= len(l.rendered) {
+		return ""
+	}
+	endOffset = min(endOffset, len(l.rendered))
+
+	return l.rendered[startOffset:endOffset]
+}
+
+// getLine returns a single line from the rendered content using lineOffsets.
+// This avoids allocating a new string for each line like strings.Split does.
+func (l *list[T]) getLine(index int) string {
+	if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) {
+		return ""
+	}
+
+	startOffset := l.lineOffsets[index]
+	var endOffset int
+	if index+1 < len(l.lineOffsets) {
+		endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline
+	} else {
+		endOffset = len(l.rendered)
+	}
+
+	if startOffset >= len(l.rendered) {
+		return ""
+	}
+	endOffset = min(endOffset, len(l.rendered))
+
+	return l.rendered[startOffset:endOffset]
+}
+
+// lineCount returns the number of lines in the rendered content.
+func (l *list[T]) lineCount() int {
+	return len(l.lineOffsets)
+}
+
 func (l *list[T]) recalculateItemPositions() {
-	currentContentHeight := 0
-	for _, item := range slices.Collect(l.items.Seq()) {
-		rItem, ok := l.renderedItems.Get(item.ID())
+	l.recalculateItemPositionsFrom(0)
+}
+
+func (l *list[T]) recalculateItemPositionsFrom(startIdx int) {
+	var currentContentHeight int
+
+	if startIdx > 0 && startIdx <= len(l.items) {
+		prevItem := l.items[startIdx-1]
+		if rItem, ok := l.renderedItems[prevItem.ID()]; ok {
+			currentContentHeight = rItem.end + 1 + l.gap
+		}
+	}
+
+	for i := startIdx; i < len(l.items); i++ {
+		item := l.items[i]
+		rItem, ok := l.renderedItems[item.ID()]
 		if !ok {
 			continue
 		}
 		rItem.start = currentContentHeight
 		rItem.end = currentContentHeight + rItem.height - 1
-		l.renderedItems.Set(item.ID(), rItem)
+		l.renderedItems[item.ID()] = rItem
 		currentContentHeight = rItem.end + 1 + l.gap
 	}
 }
 
 func (l *list[T]) render() tea.Cmd {
-	if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
+	if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
 		return nil
 	}
 	l.setDefaultSelected()
@@ -512,51 +665,38 @@ func (l *list[T]) render() tea.Cmd {
 	} else {
 		focusChangeCmd = l.blurSelectedItem()
 	}
-	// we are not rendering the first time
 	if l.rendered != "" {
-		// rerender everything will mostly hit cache
-		l.renderMu.Lock()
-		l.rendered, _ = l.renderIterator(0, false, "")
-		l.renderMu.Unlock()
+		rendered, _ := l.renderIterator(0, false, "")
+		l.setRendered(rendered)
 		if l.direction == DirectionBackward {
 			l.recalculateItemPositions()
 		}
-		// in the end scroll to the selected item
 		if l.focused {
 			l.scrollToSelection()
 		}
 		return focusChangeCmd
 	}
-	l.renderMu.Lock()
 	rendered, finishIndex := l.renderIterator(0, true, "")
-	l.rendered = rendered
-	l.renderMu.Unlock()
-	// recalculate for the initial items
+	l.setRendered(rendered)
 	if l.direction == DirectionBackward {
 		l.recalculateItemPositions()
 	}
-	renderCmd := func() tea.Msg {
-		l.offset = 0
-		// render the rest
 
-		l.renderMu.Lock()
-		l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
-		l.renderMu.Unlock()
-		// needed for backwards
-		if l.direction == DirectionBackward {
-			l.recalculateItemPositions()
-		}
-		// in the end scroll to the selected item
-		if l.focused {
-			l.scrollToSelection()
-		}
-		return nil
+	l.offset = 0
+	rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
+	l.setRendered(rendered)
+	if l.direction == DirectionBackward {
+		l.recalculateItemPositions()
 	}
-	return tea.Batch(focusChangeCmd, renderCmd)
+	if l.focused {
+		l.scrollToSelection()
+	}
+
+	return focusChangeCmd
 }
 
 func (l *list[T]) setDefaultSelected() {
-	if l.selectedItem == "" {
+	if l.selectedItemIdx < 0 {
 		if l.direction == DirectionForward {
 			l.selectFirstItem()
 		} else {
@@ -566,27 +706,29 @@ func (l *list[T]) setDefaultSelected() {
 }
 
 func (l *list[T]) scrollToSelection() {
-	rItem, ok := l.renderedItems.Get(l.selectedItem)
+	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
+		l.selectedItemIdx = -1
+		l.setDefaultSelected()
+		return
+	}
+	item := l.items[l.selectedItemIdx]
+	rItem, ok := l.renderedItems[item.ID()]
 	if !ok {
-		l.selectedItem = ""
+		l.selectedItemIdx = -1
 		l.setDefaultSelected()
 		return
 	}
 
 	start, end := l.viewPosition()
-	// item bigger or equal to the viewport do nothing
 	if rItem.start <= start && rItem.end >= end {
 		return
 	}
-	// if we are moving by item we want to move the offset so that the
-	// whole item is visible not just portions of it
 	if l.movingByItem {
 		if rItem.start >= start && rItem.end <= end {
 			return
 		}
 		defer func() { l.movingByItem = false }()
 	} else {
-		// item already in view do nothing
 		if rItem.start >= start && rItem.start <= end {
 			return
 		}
@@ -599,14 +741,13 @@ func (l *list[T]) scrollToSelection() {
 		if l.direction == DirectionForward {
 			l.offset = rItem.start
 		} else {
-			l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
+			l.offset = max(0, l.renderedHeight-(rItem.start+l.height))
 		}
 		return
 	}
 
-	renderedLines := lipgloss.Height(l.rendered) - 1
+	renderedLines := l.renderedHeight - 1
 
-	// If item is above the viewport, make it the first item
 	if rItem.start < start {
 		if l.direction == DirectionForward {
 			l.offset = rItem.start
@@ -614,7 +755,6 @@ func (l *list[T]) scrollToSelection() {
 			l.offset = max(0, renderedLines-rItem.start-l.height+1)
 		}
 	} else if rItem.end > end {
-		// If item is below the viewport, make it the last item
 		if l.direction == DirectionForward {
 			l.offset = max(0, rItem.end-l.height+1)
 		} else {
@@ -624,7 +764,11 @@ func (l *list[T]) scrollToSelection() {
 }
 
 func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
-	rItem, ok := l.renderedItems.Get(l.selectedItem)
+	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
+		return nil
+	}
+	item := l.items[l.selectedItemIdx]
+	rItem, ok := l.renderedItems[item.ID()]
 	if !ok {
 		return nil
 	}
@@ -643,64 +787,60 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 	if itemMiddle < start {
 		// select the first item in the viewport
 		// the item is most likely an item coming after this item
-		inx, ok := l.indexMap.Get(rItem.id)
-		if !ok {
-			return nil
-		}
+		inx := l.selectedItemIdx
 		for {
 			inx = l.firstSelectableItemBelow(inx)
 			if inx == ItemNotFound {
 				return nil
 			}
-			item, ok := l.items.Get(inx)
-			if !ok {
+			if inx < 0 || inx >= len(l.items) {
 				continue
 			}
-			renderedItem, ok := l.renderedItems.Get(item.ID())
+
+			item := l.items[inx]
+			renderedItem, ok := l.renderedItems[item.ID()]
 			if !ok {
 				continue
 			}
 
 			// If the item is bigger than the viewport, select it
 			if renderedItem.start <= start && renderedItem.end >= end {
-				l.selectedItem = renderedItem.id
+				l.selectedItemIdx = inx
 				return l.render()
 			}
 			// item is in the view
 			if renderedItem.start >= start && renderedItem.start <= end {
-				l.selectedItem = renderedItem.id
+				l.selectedItemIdx = inx
 				return l.render()
 			}
 		}
 	} else if itemMiddle > end {
 		// select the first item in the viewport
 		// the item is most likely an item coming after this item
-		inx, ok := l.indexMap.Get(rItem.id)
-		if !ok {
-			return nil
-		}
+		inx := l.selectedItemIdx
 		for {
 			inx = l.firstSelectableItemAbove(inx)
 			if inx == ItemNotFound {
 				return nil
 			}
-			item, ok := l.items.Get(inx)
-			if !ok {
+			if inx < 0 || inx >= len(l.items) {
 				continue
 			}
-			renderedItem, ok := l.renderedItems.Get(item.ID())
+
+			item := l.items[inx]
+			renderedItem, ok := l.renderedItems[item.ID()]
 			if !ok {
 				continue
 			}
 
 			// If the item is bigger than the viewport, select it
 			if renderedItem.start <= start && renderedItem.end >= end {
-				l.selectedItem = renderedItem.id
+				l.selectedItemIdx = inx
 				return l.render()
 			}
 			// item is in the view
 			if renderedItem.end >= start && renderedItem.end <= end {
-				l.selectedItem = renderedItem.id
+				l.selectedItemIdx = inx
 				return l.render()
 			}
 		}
@@ -711,46 +851,42 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 func (l *list[T]) selectFirstItem() {
 	inx := l.firstSelectableItemBelow(-1)
 	if inx != ItemNotFound {
-		item, ok := l.items.Get(inx)
-		if ok {
-			l.selectedItem = item.ID()
-		}
+		l.selectedItemIdx = inx
 	}
 }
 
 func (l *list[T]) selectLastItem() {
-	inx := l.firstSelectableItemAbove(l.items.Len())
+	inx := l.firstSelectableItemAbove(len(l.items))
 	if inx != ItemNotFound {
-		item, ok := l.items.Get(inx)
-		if ok {
-			l.selectedItem = item.ID()
-		}
+		l.selectedItemIdx = inx
 	}
 }
 
 func (l *list[T]) firstSelectableItemAbove(inx int) int {
 	for i := inx - 1; i >= 0; i-- {
-		item, ok := l.items.Get(i)
-		if !ok {
+		if i < 0 || i >= len(l.items) {
 			continue
 		}
+
+		item := l.items[i]
 		if _, ok := any(item).(layout.Focusable); ok {
 			return i
 		}
 	}
 	if inx == 0 && l.wrap {
-		return l.firstSelectableItemAbove(l.items.Len())
+		return l.firstSelectableItemAbove(len(l.items))
 	}
 	return ItemNotFound
 }
 
 func (l *list[T]) firstSelectableItemBelow(inx int) int {
-	itemsLen := l.items.Len()
+	itemsLen := len(l.items)
 	for i := inx + 1; i < itemsLen; i++ {
-		item, ok := l.items.Get(i)
-		if !ok {
+		if i < 0 || i >= len(l.items) {
 			continue
 		}
+
+		item := l.items[i]
 		if _, ok := any(item).(layout.Focusable); ok {
 			return i
 		}
@@ -762,38 +898,52 @@ func (l *list[T]) firstSelectableItemBelow(inx int) int {
 }
 
 func (l *list[T]) focusSelectedItem() tea.Cmd {
-	if l.selectedItem == "" || !l.focused {
+	if l.selectedItemIdx < 0 || !l.focused {
 		return nil
 	}
-	var cmds []tea.Cmd
-	for _, item := range slices.Collect(l.items.Seq()) {
-		if f, ok := any(item).(layout.Focusable); ok {
-			if item.ID() == l.selectedItem && !f.IsFocused() {
-				cmds = append(cmds, f.Focus())
-				l.renderedItems.Del(item.ID())
-			} else if item.ID() != l.selectedItem && f.IsFocused() {
-				cmds = append(cmds, f.Blur())
-				l.renderedItems.Del(item.ID())
-			}
+	// Pre-allocate with expected capacity
+	cmds := make([]tea.Cmd, 0, 2)
+
+	// Blur the previously selected item if it's different
+	if l.prevSelectedItemIdx >= 0 && l.prevSelectedItemIdx != l.selectedItemIdx && l.prevSelectedItemIdx < len(l.items) {
+		prevItem := l.items[l.prevSelectedItemIdx]
+		if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() {
+			cmds = append(cmds, f.Blur())
+			// Mark cache as needing update, but don't delete yet
+			// This allows the render to potentially reuse it
+			delete(l.renderedItems, prevItem.ID())
 		}
 	}
+
+	// Focus the currently selected item
+	if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
+		item := l.items[l.selectedItemIdx]
+		if f, ok := any(item).(layout.Focusable); ok && !f.IsFocused() {
+			cmds = append(cmds, f.Focus())
+			// Mark for re-render
+			delete(l.renderedItems, item.ID())
+		}
+	}
+
+	l.prevSelectedItemIdx = l.selectedItemIdx
 	return tea.Batch(cmds...)
 }
 
 func (l *list[T]) blurSelectedItem() tea.Cmd {
-	if l.selectedItem == "" || l.focused {
+	if l.selectedItemIdx < 0 || l.focused {
 		return nil
 	}
-	var cmds []tea.Cmd
-	for _, item := range slices.Collect(l.items.Seq()) {
-		if f, ok := any(item).(layout.Focusable); ok {
-			if item.ID() == l.selectedItem && f.IsFocused() {
-				cmds = append(cmds, f.Blur())
-				l.renderedItems.Del(item.ID())
-			}
+
+	// Blur the currently selected item
+	if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
+		item := l.items[l.selectedItemIdx]
+		if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() {
+			delete(l.renderedItems, item.ID())
+			return f.Blur()
 		}
 	}
-	return tea.Batch(cmds...)
+
+	return nil
 }
 
 // renderFragment holds updated rendered view fragments
@@ -806,10 +956,15 @@ type renderFragment struct {
 // returns the last index and the rendered content so far
 // we pass the rendered content around and don't use l.rendered to prevent jumping of the content
 func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
-	var fragments []renderFragment
+	// Pre-allocate fragments with expected capacity
+	itemsLen := len(l.items)
+	expectedFragments := itemsLen - startInx
+	if limitHeight && l.height > 0 {
+		expectedFragments = min(expectedFragments, l.height)
+	}
+	fragments := make([]renderFragment, 0, expectedFragments)
 
 	currentContentHeight := lipgloss.Height(rendered) - 1
-	itemsLen := l.items.Len()
 	finalIndex := itemsLen
 
 	// first pass: accumulate all fragments to render until the height limit is
@@ -826,19 +981,20 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
 			inx = (itemsLen - 1) - i
 		}
 
-		item, ok := l.items.Get(inx)
-		if !ok {
+		if inx < 0 || inx >= len(l.items) {
 			continue
 		}
 
+		item := l.items[inx]
+
 		var rItem renderedItem
-		if cache, ok := l.renderedItems.Get(item.ID()); ok {
+		if cache, ok := l.renderedItems[item.ID()]; ok {
 			rItem = cache
 		} else {
 			rItem = l.renderItem(item)
 			rItem.start = currentContentHeight
 			rItem.end = currentContentHeight + rItem.height - 1
-			l.renderedItems.Set(item.ID(), rItem)
+			l.renderedItems[item.ID()] = rItem
 		}
 
 		gap := l.gap + 1
@@ -853,12 +1009,26 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
 
 	// second pass: build rendered string efficiently
 	var b strings.Builder
+
+	// Pre-size the builder to reduce allocations
+	estimatedSize := len(rendered)
+	for _, f := range fragments {
+		estimatedSize += len(f.view) + f.gap
+	}
+	b.Grow(estimatedSize)
+
 	if l.direction == DirectionForward {
 		b.WriteString(rendered)
-		for _, f := range fragments {
+		for i := range fragments {
+			f := &fragments[i]
 			b.WriteString(f.view)
-			for range f.gap {
-				b.WriteByte('\n')
+			// Optimized gap writing using pre-allocated buffer
+			if f.gap > 0 {
+				if f.gap <= maxGapSize {
+					b.WriteString(newlineBuffer[:f.gap])
+				} else {
+					b.WriteString(strings.Repeat("\n", f.gap))
+				}
 			}
 		}
 
@@ -867,10 +1037,15 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
 
 	// iterate backwards as fragments are in reversed order
 	for i := len(fragments) - 1; i >= 0; i-- {
-		f := fragments[i]
+		f := &fragments[i]
 		b.WriteString(f.view)
-		for range f.gap {
-			b.WriteByte('\n')
+		// Optimized gap writing using pre-allocated buffer
+		if f.gap > 0 {
+			if f.gap <= maxGapSize {
+				b.WriteString(newlineBuffer[:f.gap])
+			} else {
+				b.WriteString(strings.Repeat("\n", f.gap))
+			}
 		}
 	}
 	b.WriteString(rendered)
@@ -881,7 +1056,6 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
 func (l *list[T]) renderItem(item Item) renderedItem {
 	view := item.View()
 	return renderedItem{
-		id:     item.ID(),
 		view:   view,
 		height: lipgloss.Height(view),
 	}
@@ -889,17 +1063,17 @@ func (l *list[T]) renderItem(item Item) renderedItem {
 
 // AppendItem implements List.
 func (l *list[T]) AppendItem(item T) tea.Cmd {
-	var cmds []tea.Cmd
+	// Pre-allocate with expected capacity
+	cmds := make([]tea.Cmd, 0, 4)
 	cmd := item.Init()
 	if cmd != nil {
 		cmds = append(cmds, cmd)
 	}
 
-	l.items.Append(item)
-	l.indexMap = csync.NewMap[string, int]()
-	for inx, item := range slices.Collect(l.items.Seq()) {
-		l.indexMap.Set(item.ID(), inx)
-	}
+	newIndex := len(l.items)
+	l.items = append(l.items, item)
+	l.indexMap[item.ID()] = newIndex
+
 	if l.width > 0 && l.height > 0 {
 		cmd = item.SetSize(l.width, l.height)
 		if cmd != nil {
@@ -917,13 +1091,13 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
 				cmds = append(cmds, cmd)
 			}
 		} else {
-			newItem, ok := l.renderedItems.Get(item.ID())
+			newItem, ok := l.renderedItems[item.ID()]
 			if ok {
 				newLines := newItem.height
-				if l.items.Len() > 1 {
+				if len(l.items) > 1 {
 					newLines += l.gap
 				}
-				l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
+				l.offset = min(l.renderedHeight-1, l.offset+newLines)
 			}
 		}
 	}
@@ -938,35 +1112,41 @@ func (l *list[T]) Blur() tea.Cmd {
 
 // DeleteItem implements List.
 func (l *list[T]) DeleteItem(id string) tea.Cmd {
-	inx, ok := l.indexMap.Get(id)
+	inx, ok := l.indexMap[id]
 	if !ok {
 		return nil
 	}
-	l.items.Delete(inx)
-	l.renderedItems.Del(id)
-	for inx, item := range slices.Collect(l.items.Seq()) {
-		l.indexMap.Set(item.ID(), inx)
+	l.items = append(l.items[:inx], l.items[inx+1:]...)
+	delete(l.renderedItems, id)
+	delete(l.indexMap, id)
+
+	// Only update indices for items after the deleted one
+	itemsLen := len(l.items)
+	for i := inx; i < itemsLen; i++ {
+		if i >= 0 && i < len(l.items) {
+			item := l.items[i]
+			l.indexMap[item.ID()] = i
+		}
 	}
 
-	if l.selectedItem == id {
+	// Adjust selectedItemIdx if the deleted item was selected or before it
+	if l.selectedItemIdx == inx {
+		// Deleted item was selected, select the previous item if possible
 		if inx > 0 {
-			item, ok := l.items.Get(inx - 1)
-			if ok {
-				l.selectedItem = item.ID()
-			} else {
-				l.selectedItem = ""
-			}
+			l.selectedItemIdx = inx - 1
 		} else {
-			l.selectedItem = ""
+			l.selectedItemIdx = -1
 		}
+	} else if l.selectedItemIdx > inx {
+		// Selected item is after the deleted one, shift index down
+		l.selectedItemIdx--
 	}
 	cmd := l.render()
 	if l.rendered != "" {
-		renderedHeight := lipgloss.Height(l.rendered)
-		if renderedHeight <= l.height {
+		if l.renderedHeight <= l.height {
 			l.offset = 0
 		} else {
-			maxOffset := renderedHeight - l.height
+			maxOffset := l.renderedHeight - l.height
 			if l.offset > maxOffset {
 				l.offset = maxOffset
 			}
@@ -989,7 +1169,7 @@ func (l *list[T]) GetSize() (int, int) {
 // GoToBottom implements List.
 func (l *list[T]) GoToBottom() tea.Cmd {
 	l.offset = 0
-	l.selectedItem = ""
+	l.selectedItemIdx = -1
 	l.direction = DirectionBackward
 	return l.render()
 }
@@ -997,7 +1177,7 @@ func (l *list[T]) GoToBottom() tea.Cmd {
 // GoToTop implements List.
 func (l *list[T]) GoToTop() tea.Cmd {
 	l.offset = 0
-	l.selectedItem = ""
+	l.selectedItemIdx = -1
 	l.direction = DirectionForward
 	return l.render()
 }
@@ -1009,21 +1189,29 @@ func (l *list[T]) IsFocused() bool {
 
 // Items implements List.
 func (l *list[T]) Items() []T {
-	return slices.Collect(l.items.Seq())
+	itemsLen := len(l.items)
+	result := make([]T, 0, itemsLen)
+	for i := range itemsLen {
+		if i >= 0 && i < len(l.items) {
+			item := l.items[i]
+			result = append(result, item)
+		}
+	}
+	return result
 }
 
 func (l *list[T]) incrementOffset(n int) {
-	renderedHeight := lipgloss.Height(l.rendered)
 	// no need for offset
-	if renderedHeight <= l.height {
+	if l.renderedHeight <= l.height {
 		return
 	}
-	maxOffset := renderedHeight - l.height
+	maxOffset := l.renderedHeight - l.height
 	n = min(n, maxOffset-l.offset)
 	if n <= 0 {
 		return
 	}
 	l.offset += n
+	l.cachedViewDirty = true
 }
 
 func (l *list[T]) decrementOffset(n int) {
@@ -1035,6 +1223,7 @@ func (l *list[T]) decrementOffset(n int) {
 	if l.offset < 0 {
 		l.offset = 0
 	}
+	l.cachedViewDirty = true
 }
 
 // MoveDown implements List.
@@ -1105,14 +1294,26 @@ func (l *list[T]) MoveUp(n int) tea.Cmd {
 
 // PrependItem implements List.
 func (l *list[T]) PrependItem(item T) tea.Cmd {
-	cmds := []tea.Cmd{
-		item.Init(),
+	// Pre-allocate with expected capacity
+	cmds := make([]tea.Cmd, 0, 4)
+	cmds = append(cmds, item.Init())
+
+	l.items = append([]T{item}, l.items...)
+
+	// Shift selectedItemIdx since all items moved down by 1
+	if l.selectedItemIdx >= 0 {
+		l.selectedItemIdx++
 	}
-	l.items.Prepend(item)
-	l.indexMap = csync.NewMap[string, int]()
-	for inx, item := range slices.Collect(l.items.Seq()) {
-		l.indexMap.Set(item.ID(), inx)
+
+	// Update index map incrementally: shift all existing indices up by 1
+	// This is more efficient than rebuilding from scratch
+	newIndexMap := make(map[string]int, len(l.indexMap)+1)
+	for id, idx := range l.indexMap {
+		newIndexMap[id] = idx + 1 // All existing items shift down by 1
 	}
+	newIndexMap[item.ID()] = 0 // New item is at index 0
+	l.indexMap = newIndexMap
+
 	if l.width > 0 && l.height > 0 {
 		cmds = append(cmds, item.SetSize(l.width, l.height))
 	}
@@ -1124,13 +1325,13 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
 				cmds = append(cmds, cmd)
 			}
 		} else {
-			newItem, ok := l.renderedItems.Get(item.ID())
+			newItem, ok := l.renderedItems[item.ID()]
 			if ok {
 				newLines := newItem.height
-				if l.items.Len() > 1 {
+				if len(l.items) > 1 {
 					newLines += l.gap
 				}
-				l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
+				l.offset = min(l.renderedHeight-1, l.offset+newLines)
 			}
 		}
 	}
@@ -1139,17 +1340,17 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
 
 // SelectItemAbove implements List.
 func (l *list[T]) SelectItemAbove() tea.Cmd {
-	inx, ok := l.indexMap.Get(l.selectedItem)
-	if !ok {
+	if l.selectedItemIdx < 0 {
 		return nil
 	}
 
-	newIndex := l.firstSelectableItemAbove(inx)
+	newIndex := l.firstSelectableItemAbove(l.selectedItemIdx)
 	if newIndex == ItemNotFound {
 		// no item above
 		return nil
 	}
-	var cmds []tea.Cmd
+	// Pre-allocate with expected capacity
+	cmds := make([]tea.Cmd, 0, 2)
 	if newIndex == 1 {
 		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
 		if peakAboveIndex == ItemNotFound {
@@ -1160,11 +1361,11 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
 			}
 		}
 	}
-	item, ok := l.items.Get(newIndex)
-	if !ok {
+	if newIndex < 0 || newIndex >= len(l.items) {
 		return nil
 	}
-	l.selectedItem = item.ID()
+	l.prevSelectedItemIdx = l.selectedItemIdx
+	l.selectedItemIdx = newIndex
 	l.movingByItem = true
 	renderCmd := l.render()
 	if renderCmd != nil {
@@ -1175,46 +1376,38 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
 
 // SelectItemBelow implements List.
 func (l *list[T]) SelectItemBelow() tea.Cmd {
-	inx, ok := l.indexMap.Get(l.selectedItem)
-	if !ok {
+	if l.selectedItemIdx < 0 {
 		return nil
 	}
 
-	newIndex := l.firstSelectableItemBelow(inx)
+	newIndex := l.firstSelectableItemBelow(l.selectedItemIdx)
 	if newIndex == ItemNotFound {
 		// no item above
 		return nil
 	}
-	item, ok := l.items.Get(newIndex)
-	if !ok {
+	if newIndex < 0 || newIndex >= len(l.items) {
 		return nil
 	}
-	l.selectedItem = item.ID()
+	l.prevSelectedItemIdx = l.selectedItemIdx
+	l.selectedItemIdx = newIndex
 	l.movingByItem = true
 	return l.render()
 }
 
 // SelectedItem implements List.
 func (l *list[T]) SelectedItem() *T {
-	inx, ok := l.indexMap.Get(l.selectedItem)
-	if !ok {
-		return nil
-	}
-	if inx > l.items.Len()-1 {
-		return nil
-	}
-	item, ok := l.items.Get(inx)
-	if !ok {
+	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
 		return nil
 	}
+	item := l.items[l.selectedItemIdx]
 	return &item
 }
 
 // SetItems implements List.
 func (l *list[T]) SetItems(items []T) tea.Cmd {
-	l.items.SetSlice(items)
+	l.items = items
 	var cmds []tea.Cmd
-	for inx, item := range slices.Collect(l.items.Seq()) {
+	for inx, item := range items {
 		if i, ok := any(item).(Indexable); ok {
 			i.SetIndex(inx)
 		}
@@ -1226,23 +1419,44 @@ func (l *list[T]) SetItems(items []T) tea.Cmd {
 
 // SetSelected implements List.
 func (l *list[T]) SetSelected(id string) tea.Cmd {
-	l.selectedItem = id
+	l.prevSelectedItemIdx = l.selectedItemIdx
+	if idx, ok := l.indexMap[id]; ok {
+		l.selectedItemIdx = idx
+	} else {
+		l.selectedItemIdx = -1
+	}
 	return l.render()
 }
 
-func (l *list[T]) reset(selectedItem string) tea.Cmd {
+func (l *list[T]) reset(selectedItemID string) tea.Cmd {
 	var cmds []tea.Cmd
 	l.rendered = ""
+	l.renderedHeight = 0
 	l.offset = 0
-	l.selectedItem = selectedItem
-	l.indexMap = csync.NewMap[string, int]()
-	l.renderedItems = csync.NewMap[string, renderedItem]()
-	for inx, item := range slices.Collect(l.items.Seq()) {
-		l.indexMap.Set(item.ID(), inx)
+	l.indexMap = make(map[string]int)
+	l.renderedItems = make(map[string]renderedItem)
+	itemsLen := len(l.items)
+	for i := range itemsLen {
+		if i < 0 || i >= len(l.items) {
+			continue
+		}
+
+		item := l.items[i]
+		l.indexMap[item.ID()] = i
 		if l.width > 0 && l.height > 0 {
 			cmds = append(cmds, item.SetSize(l.width, l.height))
 		}
 	}
+	// Convert selectedItemID to index after rebuilding indexMap
+	if selectedItemID != "" {
+		if idx, ok := l.indexMap[selectedItemID]; ok {
+			l.selectedItemIdx = idx
+		} else {
+			l.selectedItemIdx = -1
+		}
+	} else {
+		l.selectedItemIdx = -1
+	}
 	cmds = append(cmds, l.render())
 	return tea.Batch(cmds...)
 }
@@ -1253,7 +1467,13 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
 	l.width = width
 	l.height = height
 	if oldWidth != width {
-		cmd := l.reset(l.selectedItem)
+		// Get current selected item ID before reset
+		selectedID := ""
+		if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
+			item := l.items[l.selectedItemIdx]
+			selectedID = item.ID()
+		}
+		cmd := l.reset(selectedID)
 		return cmd
 	}
 	return nil
@@ -1261,16 +1481,17 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
 
 // UpdateItem implements List.
 func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
-	var cmds []tea.Cmd
-	if inx, ok := l.indexMap.Get(id); ok {
-		l.items.Set(inx, item)
-		oldItem, hasOldItem := l.renderedItems.Get(id)
+	// Pre-allocate with expected capacity
+	cmds := make([]tea.Cmd, 0, 1)
+	if inx, ok := l.indexMap[id]; ok {
+		l.items[inx] = item
+		oldItem, hasOldItem := l.renderedItems[id]
 		oldPosition := l.offset
 		if l.direction == DirectionBackward {
-			oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
+			oldPosition = (l.renderedHeight - 1) - l.offset
 		}
 
-		l.renderedItems.Del(id)
+		delete(l.renderedItems, id)
 		cmd := l.render()
 
 		// need to check for nil because of sequence not handling nil
@@ -1281,17 +1502,17 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
 			// if we are the last item and there is no offset
 			// make sure to go to the bottom
 			if oldPosition < oldItem.end {
-				newItem, ok := l.renderedItems.Get(item.ID())
+				newItem, ok := l.renderedItems[item.ID()]
 				if ok {
 					newLines := newItem.height - oldItem.height
-					l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+					l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
 				}
 			}
 		} else if hasOldItem && l.offset > oldItem.start {
-			newItem, ok := l.renderedItems.Get(item.ID())
+			newItem, ok := l.renderedItems[item.ID()]
 			if ok {
 				newLines := newItem.height - oldItem.height
-				l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+				l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
 			}
 		}
 	}
@@ -1333,13 +1554,10 @@ func (l *list[T]) SelectionClear() {
 }
 
 func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
-	lines := strings.Split(l.rendered, "\n")
-	for i, l := range lines {
-		lines[i] = ansi.Strip(l)
-	}
+	numLines := l.lineCount()
 
-	if l.direction == DirectionBackward && len(lines) > l.height {
-		line = ((len(lines) - 1) - l.height) + line + 1
+	if l.direction == DirectionBackward && numLines > l.height {
+		line = ((numLines - 1) - l.height) + line + 1
 	}
 
 	if l.offset > 0 {
@@ -1350,11 +1568,11 @@ func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
 		}
 	}
 
-	if line < 0 || line >= len(lines) {
+	if line < 0 || line >= numLines {
 		return 0, 0
 	}
 
-	currentLine := lines[line]
+	currentLine := ansi.Strip(l.getLine(line))
 	gr := uniseg.NewGraphemes(currentLine)
 	startCol = -1
 	upTo := col
@@ -1377,15 +1595,19 @@ func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
 }
 
 func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
-	lines := strings.Split(l.rendered, "\n")
-	for i, l := range lines {
-		lines[i] = ansi.Strip(l)
+	// Helper function to get a line with ANSI stripped and icons replaced
+	getCleanLine := func(index int) string {
+		rawLine := l.getLine(index)
+		cleanLine := ansi.Strip(rawLine)
 		for _, icon := range styles.SelectionIgnoreIcons {
-			lines[i] = strings.ReplaceAll(lines[i], icon, " ")
+			cleanLine = strings.ReplaceAll(cleanLine, icon, " ")
 		}
+		return cleanLine
 	}
-	if l.direction == DirectionBackward && len(lines) > l.height {
-		line = (len(lines) - 1) - l.height + line + 1
+
+	numLines := l.lineCount()
+	if l.direction == DirectionBackward && numLines > l.height {
+		line = (numLines - 1) - l.height + line + 1
 	}
 
 	if l.offset > 0 {
@@ -1397,30 +1619,30 @@ func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, fou
 	}
 
 	// Ensure line is within bounds
-	if line < 0 || line >= len(lines) {
+	if line < 0 || line >= numLines {
 		return 0, 0, false
 	}
 
-	if strings.TrimSpace(lines[line]) == "" {
+	if strings.TrimSpace(getCleanLine(line)) == "" {
 		return 0, 0, false
 	}
 
 	// Find start of paragraph (search backwards for empty line or start of text)
 	startLine = line
-	for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
+	for startLine > 0 && strings.TrimSpace(getCleanLine(startLine-1)) != "" {
 		startLine--
 	}
 
 	// Find end of paragraph (search forwards for empty line or end of text)
 	endLine = line
-	for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
+	for endLine < numLines-1 && strings.TrimSpace(getCleanLine(endLine+1)) != "" {
 		endLine++
 	}
 
 	// revert the line numbers if we are in backward direction
-	if l.direction == DirectionBackward && len(lines) > l.height {
-		startLine = startLine - (len(lines) - 1) + l.height - 1
-		endLine = endLine - (len(lines) - 1) + l.height - 1
+	if l.direction == DirectionBackward && numLines > l.height {
+		startLine = startLine - (numLines - 1) + l.height - 1
+		endLine = endLine - (numLines - 1) + l.height - 1
 	}
 	if l.offset > 0 {
 		if l.direction == DirectionBackward {
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
index d618063e..1d3d93ce 100644
--- a/internal/tui/keys.go
+++ b/internal/tui/keys.go
@@ -1,7 +1,7 @@
 package tui
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 type KeyMap struct {
diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go
index 29189250..c478701e 100644
--- a/internal/tui/page/chat/chat.go
+++ b/internal/tui/page/chat/chat.go
@@ -2,14 +2,15 @@ package chat
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"time"
 
-	"github.com/charmbracelet/bubbles/v2/help"
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/spinner"
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/spinner"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/history"
@@ -36,7 +37,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"
 )
 
 var ChatPageID page.PageID = "chat"
@@ -163,7 +163,7 @@ func (p *chatPage) Init() tea.Cmd {
 	)
 }
 
-func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case tea.KeyboardEnhancementsMsg:
@@ -331,7 +331,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return p, tea.Batch(cmds...)
 
 	case commands.CommandRunCustomMsg:
-		if p.app.CoderAgent.IsBusy() {
+		if p.app.AgentCoordinator.IsBusy() {
 			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
 		}
 
@@ -346,7 +346,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			p.splashFullScreen = true
 			return p, p.SetSize(p.width, p.height)
 		}
-		err := p.app.InitCoderAgent()
+		err := p.app.InitCoderAgent(context.TODO())
 		if err != nil {
 			return p, util.ReportError(err)
 		}
@@ -355,7 +355,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		p.focusedPane = PanelTypeEditor
 		return p, p.SetSize(p.width, p.height)
 	case commands.NewSessionsMsg:
-		if p.app.CoderAgent.IsBusy() {
+		if p.app.AgentCoordinator.IsBusy() {
 			return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 		}
 		return p, p.newSession()
@@ -363,15 +363,15 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch {
 		case key.Matches(msg, p.keyMap.NewSession):
 			// if we have no agent do nothing
-			if p.app.CoderAgent == nil {
+			if p.app.AgentCoordinator == nil {
 				return p, nil
 			}
-			if p.app.CoderAgent.IsBusy() {
+			if p.app.AgentCoordinator.IsBusy() {
 				return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 			}
 			return p, p.newSession()
 		case key.Matches(msg, p.keyMap.AddAttachment):
-			agentCfg := config.Get().Agents["coder"]
+			agentCfg := config.Get().Agents[config.AgentCoder]
 			model := config.Get().GetModelByType(agentCfg.Model)
 			if model.SupportsImages {
 				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
@@ -387,7 +387,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			p.changeFocus()
 			return p, nil
 		case key.Matches(msg, p.keyMap.Cancel):
-			if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
+			if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
 				return p, p.cancel()
 			}
 		case key.Matches(msg, p.keyMap.Details):
@@ -530,21 +530,21 @@ func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 func (p *chatPage) toggleThinking() tea.Cmd {
 	return func() tea.Msg {
 		cfg := config.Get()
-		agentCfg := cfg.Agents["coder"]
+		agentCfg := cfg.Agents[config.AgentCoder]
 		currentModel := cfg.Models[agentCfg.Model]
 
 		// Toggle the thinking mode
 		currentModel.Think = !currentModel.Think
-		cfg.Models[agentCfg.Model] = currentModel
-
-		// Update the agent with the new configuration
-		if err := p.app.UpdateAgentModel(); err != nil {
+		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
 			return util.InfoMsg{
 				Type: util.InfoTypeError,
 				Msg:  "Failed to update thinking mode: " + err.Error(),
 			}
 		}
 
+		// Update the agent with the new configuration
+		go p.app.UpdateAgentModel(context.TODO())
+
 		status := "disabled"
 		if currentModel.Think {
 			status = "enabled"
@@ -559,12 +559,11 @@ func (p *chatPage) toggleThinking() tea.Cmd {
 func (p *chatPage) openReasoningDialog() tea.Cmd {
 	return func() tea.Msg {
 		cfg := config.Get()
-		agentCfg := cfg.Agents["coder"]
+		agentCfg := cfg.Agents[config.AgentCoder]
 		model := cfg.GetModelByType(agentCfg.Model)
 		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
 
-		if providerCfg != nil && model != nil &&
-			providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
+		if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 {
 			// Return the OpenDialogMsg directly so it bubbles up to the main TUI
 			return dialogs.OpenDialogMsg{
 				Model: reasoning.NewReasoningDialog(),
@@ -577,15 +576,20 @@ func (p *chatPage) openReasoningDialog() tea.Cmd {
 func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
 	return func() tea.Msg {
 		cfg := config.Get()
-		agentCfg := cfg.Agents["coder"]
+		agentCfg := cfg.Agents[config.AgentCoder]
 		currentModel := cfg.Models[agentCfg.Model]
 
 		// Update the model configuration
 		currentModel.ReasoningEffort = effort
-		cfg.Models[agentCfg.Model] = currentModel
+		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
+				Msg:  "Failed to update reasoning effort: " + err.Error(),
+			}
+		}
 
 		// Update the agent with the new configuration
-		if err := p.app.UpdateAgentModel(); err != nil {
+		if err := p.app.UpdateAgentModel(context.TODO()); err != nil {
 			return util.InfoMsg{
 				Type: util.InfoTypeError,
 				Msg:  "Failed to update reasoning effort: " + err.Error(),
@@ -706,14 +710,14 @@ func (p *chatPage) changeFocus() {
 func (p *chatPage) cancel() tea.Cmd {
 	if p.isCanceling {
 		p.isCanceling = false
-		if p.app.CoderAgent != nil {
-			p.app.CoderAgent.Cancel(p.session.ID)
+		if p.app.AgentCoordinator != nil {
+			p.app.AgentCoordinator.Cancel(p.session.ID)
 		}
 		return nil
 	}
 
-	if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
-		p.app.CoderAgent.ClearQueue(p.session.ID)
+	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
+		p.app.AgentCoordinator.ClearQueue(p.session.ID)
 		return nil
 	}
 	p.isCanceling = true
@@ -746,14 +750,25 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te
 		session = newSession
 		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 	}
-	if p.app.CoderAgent == nil {
+	if p.app.AgentCoordinator == nil {
 		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 	}
-	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
-	if err != nil {
-		return util.ReportError(err)
-	}
 	cmds = append(cmds, p.chat.GoToBottom())
+	cmds = append(cmds, func() tea.Msg {
+		_, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
+		if err != nil {
+			isCancelErr := errors.Is(err, context.Canceled)
+			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
+			if isCancelErr || isPermissionErr {
+				return nil
+			}
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
+				Msg:  err.Error(),
+			}
+		}
+		return nil
+	})
 	return tea.Batch(cmds...)
 }
 
@@ -762,7 +777,7 @@ func (p *chatPage) Bindings() []key.Binding {
 		p.keyMap.NewSession,
 		p.keyMap.AddAttachment,
 	}
-	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
+	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 		cancelBinding := p.keyMap.Cancel
 		if p.isCanceling {
 			cancelBinding = key.NewBinding(
@@ -883,7 +898,7 @@ func (p *chatPage) Help() help.KeyMap {
 			}
 			return core.NewSimpleHelp(shortList, fullList)
 		}
-		if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
+		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 			cancelBinding := key.NewBinding(
 				key.WithKeys("esc", "alt+esc"),
 				key.WithHelp("esc", "cancel"),
@@ -894,7 +909,7 @@ func (p *chatPage) Help() help.KeyMap {
 					key.WithHelp("esc", "press again to cancel"),
 				)
 			}
-			if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
+			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
 				cancelBinding = key.NewBinding(
 					key.WithKeys("esc", "alt+esc"),
 					key.WithHelp("esc", "clear queue"),
@@ -1010,7 +1025,8 @@ func (p *chatPage) Help() help.KeyMap {
 				// to reflect that.
 				key.WithHelp("ctrl+j", "newline"),
 			)
-			if p.keyboardEnhancements.SupportsKeyDisambiguation() {
+			if p.keyboardEnhancements.Flags > 0 {
+				// Non-zero flags mean we have at least key disambiguation.
 				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
 			}
 			shortList = append(shortList, newLineBinding)
@@ -1022,8 +1038,8 @@ func (p *chatPage) Help() help.KeyMap {
 						key.WithHelp("ctrl+f", "add image"),
 					),
 					key.NewBinding(
-						key.WithKeys("/"),
-						key.WithHelp("/", "add file"),
+						key.WithKeys("@"),
+						key.WithHelp("@", "mention file"),
 					),
 					key.NewBinding(
 						key.WithKeys("ctrl+o"),
diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go
index 679a97c6..b175b4bf 100644
--- a/internal/tui/page/chat/keys.go
+++ b/internal/tui/page/chat/keys.go
@@ -1,7 +1,7 @@
 package chat
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"charm.land/bubbles/v2/key"
 )
 
 type KeyMap struct {
diff --git a/internal/tui/styles/charmtone.go b/internal/tui/styles/charmtone.go
index 1d9a1942..5f2f58b7 100644
--- a/internal/tui/styles/charmtone.go
+++ b/internal/tui/styles/charmtone.go
@@ -1,7 +1,7 @@
 package styles
 
 import (
-	"github.com/charmbracelet/lipgloss/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/x/exp/charmtone"
 )
 
diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go
index deda517a..549ac87f 100644
--- a/internal/tui/styles/markdown.go
+++ b/internal/tui/styles/markdown.go
@@ -1,9 +1,19 @@
 package styles
 
 import (
+	"fmt"
+	"image/color"
+
 	"github.com/charmbracelet/glamour/v2"
+	"github.com/charmbracelet/glamour/v2/ansi"
 )
 
+// lipglossColorToHex converts a color.Color to hex string
+func lipglossColorToHex(c color.Color) string {
+	r, g, b, _ := c.RGBA()
+	return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
+}
+
 // Helper functions for style pointers
 func boolPtr(b bool) *bool       { return &b }
 func stringPtr(s string) *string { return &s }
@@ -18,3 +28,178 @@ func GetMarkdownRenderer(width int) *glamour.TermRenderer {
 	)
 	return r
 }
+
+// returns a glamour TermRenderer with no colors (plain text with structure)
+func GetPlainMarkdownRenderer(width int) *glamour.TermRenderer {
+	r, _ := glamour.NewTermRenderer(
+		glamour.WithStyles(PlainMarkdownStyle()),
+		glamour.WithWordWrap(width),
+	)
+	return r
+}
+
+// PlainMarkdownStyle returns a glamour style config with no colors
+func PlainMarkdownStyle() ansi.StyleConfig {
+	t := CurrentTheme()
+	bgColor := stringPtr(lipglossColorToHex(t.BgBaseLighter))
+	fgColor := stringPtr(lipglossColorToHex(t.FgMuted))
+	return ansi.StyleConfig{
+		Document: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		BlockQuote: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+			Indent:      uintPtr(1),
+			IndentToken: stringPtr("│ "),
+		},
+		List: ansi.StyleList{
+			LevelIndent: defaultListIndent,
+		},
+		Heading: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				BlockSuffix:     "\n",
+				Bold:            boolPtr(true),
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		H1: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Bold:            boolPtr(true),
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		H2: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "## ",
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		H3: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "### ",
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		H4: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "#### ",
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		H5: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "##### ",
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		H6: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "###### ",
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		Strikethrough: ansi.StylePrimitive{
+			CrossedOut:      boolPtr(true),
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Emph: ansi.StylePrimitive{
+			Italic:          boolPtr(true),
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Strong: ansi.StylePrimitive{
+			Bold:            boolPtr(true),
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		HorizontalRule: ansi.StylePrimitive{
+			Format:          "\n--------\n",
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Item: ansi.StylePrimitive{
+			BlockPrefix:     "• ",
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Enumeration: ansi.StylePrimitive{
+			BlockPrefix:     ". ",
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Task: ansi.StyleTask{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+			Ticked:   "[✓] ",
+			Unticked: "[ ] ",
+		},
+		Link: ansi.StylePrimitive{
+			Underline:       boolPtr(true),
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		LinkText: ansi.StylePrimitive{
+			Bold:            boolPtr(true),
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Image: ansi.StylePrimitive{
+			Underline:       boolPtr(true),
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		ImageText: ansi.StylePrimitive{
+			Format:          "Image: {{.text}} →",
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+		Code: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Color:           fgColor,
+				BackgroundColor: bgColor,
+			},
+		},
+		CodeBlock: ansi.StyleCodeBlock{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color:           fgColor,
+					BackgroundColor: bgColor,
+				},
+				Margin: uintPtr(defaultMargin),
+			},
+		},
+		Table: ansi.StyleTable{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color:           fgColor,
+					BackgroundColor: bgColor,
+				},
+			},
+		},
+		DefinitionDescription: ansi.StylePrimitive{
+			BlockPrefix:     "\n ",
+			Color:           fgColor,
+			BackgroundColor: bgColor,
+		},
+	}
+}
diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go
index a81f8484..4060cf49 100644
--- a/internal/tui/styles/theme.go
+++ b/internal/tui/styles/theme.go
@@ -5,14 +5,14 @@ import (
 	"image/color"
 	"strings"
 
-	"github.com/charmbracelet/bubbles/v2/filepicker"
-	"github.com/charmbracelet/bubbles/v2/help"
-	"github.com/charmbracelet/bubbles/v2/textarea"
-	"github.com/charmbracelet/bubbles/v2/textinput"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/filepicker"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/textarea"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
 	"github.com/charmbracelet/glamour/v2/ansi"
-	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/exp/charmtone"
 	"github.com/lucasb-eyer/go-colorful"
 	"github.com/rivo/uniseg"
@@ -179,7 +179,7 @@ func (t *Theme) buildStyles() *Styles {
 			},
 			Cursor: textinput.CursorStyle{
 				Color: t.Secondary,
-				Shape: tea.CursorBar,
+				Shape: tea.CursorBlock,
 				Blink: true,
 			},
 		},
@@ -204,7 +204,7 @@ func (t *Theme) buildStyles() *Styles {
 			},
 			Cursor: textarea.CursorStyle{
 				Color: t.Secondary,
-				Shape: tea.CursorBar,
+				Shape: tea.CursorBlock,
 				Blink: true,
 			},
 		},
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 26d23f46..25636206 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -4,15 +4,17 @@ import (
 	"context"
 	"fmt"
 	"math/rand"
+	"slices"
 	"strings"
 	"time"
 
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/event"
-	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
@@ -23,7 +25,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/core/status"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
@@ -33,7 +34,8 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/page/chat"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
 )
 
 var lastMouseEvent time.Time
@@ -74,6 +76,14 @@ type appModel struct {
 
 	// Chat Page Specific
 	selectedSessionID string // The ID of the currently selected session
+
+	// sendProgressBar instructs the TUI to send progress bar updates to the
+	// terminal.
+	sendProgressBar bool
+
+	// QueryVersion instructs the TUI to query for the terminal version when it
+	// starts.
+	QueryVersion bool
 }
 
 // Init initializes the application model and returns initial commands.
@@ -90,8 +100,9 @@ func (a appModel) Init() tea.Cmd {
 
 	cmd = a.status.Init()
 	cmds = append(cmds, cmd)
-
-	cmds = append(cmds, tea.EnableMouseAllMotion)
+	if a.QueryVersion {
+		cmds = append(cmds, tea.RequestTerminalVersion)
+	}
 
 	return tea.Batch(cmds...)
 }
@@ -103,12 +114,22 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	a.isConfigured = config.HasInitialDataConfig()
 
 	switch msg := msg.(type) {
+	case tea.EnvMsg:
+		// Is this Windows Terminal?
+		if !a.sendProgressBar {
+			a.sendProgressBar = slices.Contains(msg, "WT_SESSION")
+		}
+	case tea.TerminalVersionMsg:
+		termVersion := strings.ToLower(msg.Name)
+		// Only enable progress bar for the following terminals.
+		if !a.sendProgressBar {
+			a.sendProgressBar = strings.Contains(termVersion, "ghostty")
+		}
+		return a, nil
 	case tea.KeyboardEnhancementsMsg:
 		for id, page := range a.pages {
 			m, pageCmd := page.Update(msg)
-			if model, ok := m.(util.Model); ok {
-				a.pages[id] = model
-			}
+			a.pages[id] = m
 
 			if pageCmd != nil {
 				cmds = append(cmds, pageCmd)
@@ -120,6 +141,16 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		a.completions.Update(msg)
 		return a, a.handleWindowResize(msg.Width, msg.Height)
 
+	case pubsub.Event[mcp.Event]:
+		switch msg.Payload.Type {
+		case mcp.EventStateChanged:
+			return a, a.handleStateChanged(context.Background())
+		case mcp.EventPromptsListChanged:
+			return a, handleMCPPromptsEvent(context.Background(), msg.Payload.Name)
+		case mcp.EventToolsListChanged:
+			return a, handleMCPToolsEvent(context.Background(), msg.Payload.Name)
+		}
+
 	// Completions messages
 	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
 		completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
@@ -138,15 +169,44 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		a.dialog = u.(dialogs.DialogCmp)
 		return a, tea.Batch(completionCmd, dialogCmd)
 	case commands.ShowArgumentsDialogMsg:
+		var args []commands.Argument
+		for _, arg := range msg.ArgNames {
+			args = append(args, commands.Argument{
+				Name:     arg,
+				Title:    cases.Title(language.English).String(arg),
+				Required: true,
+			})
+		}
 		return a, util.CmdHandler(
 			dialogs.OpenDialogMsg{
 				Model: commands.NewCommandArgumentsDialog(
 					msg.CommandID,
-					msg.Content,
-					msg.ArgNames,
+					msg.CommandID,
+					msg.CommandID,
+					msg.Description,
+					args,
+					msg.OnSubmit,
 				),
 			},
 		)
+	case commands.ShowMCPPromptArgumentsDialogMsg:
+		args := make([]commands.Argument, 0, len(msg.Prompt.Arguments))
+		for _, arg := range msg.Prompt.Arguments {
+			args = append(args, commands.Argument(*arg))
+		}
+		dialog := commands.NewCommandArgumentsDialog(
+			msg.Prompt.Name,
+			msg.Prompt.Title,
+			msg.Prompt.Name,
+			msg.Prompt.Description,
+			args,
+			msg.OnSubmit,
+		)
+		return a, util.CmdHandler(
+			dialogs.OpenDialogMsg{
+				Model: dialog,
+			},
+		)
 	// Page change messages
 	case page.PageChangeMsg:
 		return a, a.moveToPage(msg.ID)
@@ -180,9 +240,13 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		)
 	// Compact
 	case commands.CompactMsg:
-		return a, util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
-		})
+		return a, func() tea.Msg {
+			err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
+			if err != nil {
+				return util.ReportError(err)()
+			}
+			return nil
+		}
 	case commands.QuitMsg:
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
 			Model: quit.NewQuitDialog(),
@@ -195,16 +259,13 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return a, a.handleWindowResize(a.wWidth, a.wHeight)
 	// Model Switch
 	case models.ModelSelectedMsg:
-		if a.app.CoderAgent.IsBusy() {
+		if a.app.AgentCoordinator.IsBusy() {
 			return a, util.ReportWarn("Agent is busy, please wait...")
 		}
 
 		config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
 
-		// Update the agent with the new model/provider configuration
-		if err := a.app.UpdateAgentModel(); err != nil {
-			return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.Model, err))
-		}
+		go a.app.UpdateAgentModel(context.TODO())
 
 		modelTypeName := "large"
 		if msg.ModelType == config.SelectedModelTypeSmall {
@@ -232,9 +293,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		// Forward to view.
 		updated, itemCmd := item.Update(msg)
-		if model, ok := updated.(util.Model); ok {
-			a.pages[a.currentPage] = model
-		}
+		a.pages[a.currentPage] = updated
 
 		return a, itemCmd
 	case pubsub.Event[permission.PermissionRequest]:
@@ -253,37 +312,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			a.app.Permissions.Deny(msg.Permission)
 		}
 		return a, nil
-	// Agent Events
-	case pubsub.Event[agent.AgentEvent]:
-		payload := msg.Payload
-
-		// Forward agent events to dialogs
-		if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() == compact.CompactDialogID {
-			u, dialogCmd := a.dialog.Update(payload)
-			if model, ok := u.(dialogs.DialogCmp); ok {
-				a.dialog = model
-			}
-
-			cmds = append(cmds, dialogCmd)
-		}
-
-		// Handle auto-compact logic
-		if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
-			// Get current session to check token usage
-			session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
-			if err == nil {
-				model := a.app.CoderAgent.Model()
-				contextWindow := model.ContextWindow
-				tokens := session.CompletionTokens + session.PromptTokens
-				if (tokens >= int64(float64(contextWindow)*0.95)) && !config.Get().Options.DisableAutoSummarize { // Show compact confirmation dialog
-					cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
-						Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
-					}))
-				}
-			}
-		}
-
-		return a, tea.Batch(cmds...)
 	case splash.OnboardingCompleteMsg:
 		item, ok := a.pages[a.currentPage]
 		if !ok {
@@ -292,9 +320,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		a.isConfigured = config.HasInitialDataConfig()
 		updated, pageCmd := item.Update(msg)
-		if model, ok := updated.(util.Model); ok {
-			a.pages[a.currentPage] = model
-		}
+		a.pages[a.currentPage] = updated
 
 		cmds = append(cmds, pageCmd)
 		return a, tea.Batch(cmds...)
@@ -314,9 +340,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 
 			updated, pageCmd := item.Update(msg)
-			if model, ok := updated.(util.Model); ok {
-				a.pages[a.currentPage] = model
-			}
+			a.pages[a.currentPage] = updated
 
 			cmds = append(cmds, pageCmd)
 		}
@@ -336,9 +360,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 
 			updated, pageCmd := item.Update(msg)
-			if model, ok := updated.(util.Model); ok {
-				a.pages[a.currentPage] = model
-			}
+			a.pages[a.currentPage] = updated
 
 			cmds = append(cmds, pageCmd)
 		}
@@ -353,9 +375,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 
 	updated, cmd := item.Update(msg)
-	if model, ok := updated.(util.Model); ok {
-		a.pages[a.currentPage] = model
-	}
+	a.pages[a.currentPage] = updated
 
 	if a.dialog.HasDialogs() {
 		u, dialogCmd := a.dialog.Update(msg)
@@ -391,9 +411,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
 	// Update the current view.
 	for p, page := range a.pages {
 		updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
-		if model, ok := updated.(util.Model); ok {
-			a.pages[p] = model
-		}
+		a.pages[p] = updated
 
 		cmds = append(cmds, pageCmd)
 	}
@@ -485,7 +503,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		)
 		return tea.Sequence(cmds...)
 	case key.Matches(msg, a.keyMap.Suspend):
-		if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
+		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
 			return util.ReportWarn("Agent is busy, please wait...")
 		}
 		return tea.Suspend
@@ -496,16 +514,14 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		}
 
 		updated, cmd := item.Update(msg)
-		if model, ok := updated.(util.Model); ok {
-			a.pages[a.currentPage] = model
-		}
+		a.pages[a.currentPage] = updated
 		return cmd
 	}
 }
 
 // moveToPage handles navigation between different pages in the application.
 func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
-	if a.app.CoderAgent.IsBusy() {
+	if a.app.AgentCoordinator.IsBusy() {
 		// TODO: maybe remove this :  For now we don't move to any page if the agent is busy
 		return util.ReportWarn("Agent is busy, please wait...")
 	}
@@ -529,6 +545,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 // View renders the complete application interface including pages, dialogs, and overlays.
 func (a *appModel) View() tea.View {
 	var view tea.View
+	view.AltScreen = true
 	t := styles.CurrentTheme()
 	view.BackgroundColor = t.BgBase
 	if a.wWidth < 25 || a.wHeight < 15 {
@@ -602,7 +619,9 @@ func (a *appModel) View() tea.View {
 
 	view.Layer = canvas
 	view.Cursor = cursor
-	if a.app != nil && a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
+	view.MouseMode = tea.MouseModeCellMotion
+
+	if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
 		// HACK: use a random percentage to prevent ghostty from hiding it
 		// after a timeout.
 		view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
@@ -610,8 +629,29 @@ func (a *appModel) View() tea.View {
 	return view
 }
 
+func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd {
+	return func() tea.Msg {
+		a.app.UpdateAgentModel(ctx)
+		return nil
+	}
+}
+
+func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
+	return func() tea.Msg {
+		mcp.RefreshPrompts(ctx, name)
+		return nil
+	}
+}
+
+func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
+	return func() tea.Msg {
+		mcp.RefreshTools(ctx, name)
+		return nil
+	}
+}
+
 // New creates and initializes a new TUI application model.
-func New(app *app.App) tea.Model {
+func New(app *app.App) *appModel {
 	chatPage := chat.New(app)
 	keyMap := DefaultKeyMap()
 	keyMap.pageBindings = chatPage.Bindings()
diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go
index eb19ad89..46af5beb 100644
--- a/internal/tui/util/util.go
+++ b/internal/tui/util/util.go
@@ -4,7 +4,7 @@ import (
 	"log/slog"
 	"time"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
 )
 
 type Cursor interface {
@@ -12,8 +12,9 @@ type Cursor interface {
 }
 
 type Model interface {
-	tea.Model
-	tea.ViewModel
+	Init() tea.Cmd
+	Update(tea.Msg) (Model, tea.Cmd)
+	View() string
 }
 
 func CmdHandler(msg tea.Msg) tea.Cmd {
diff --git a/schema.json b/schema.json
index 014155f1..093012bc 100644
--- a/schema.json
+++ b/schema.json
@@ -268,14 +268,20 @@
         "can_reason": {
           "type": "boolean"
         },
-        "has_reasoning_efforts": {
-          "type": "boolean"
+        "reasoning_levels": {
+          "items": {
+            "type": "string"
+          },
+          "type": "array"
         },
         "default_reasoning_effort": {
           "type": "string"
         },
         "supports_attachments": {
           "type": "boolean"
+        },
+        "options": {
+          "$ref": "#/$defs/ModelOptions"
         }
       },
       "additionalProperties": false,
@@ -290,10 +296,34 @@
         "context_window",
         "default_max_tokens",
         "can_reason",
-        "has_reasoning_efforts",
-        "supports_attachments"
+        "supports_attachments",
+        "options"
       ]
     },
+    "ModelOptions": {
+      "properties": {
+        "temperature": {
+          "type": "number"
+        },
+        "top_p": {
+          "type": "number"
+        },
+        "top_k": {
+          "type": "integer"
+        },
+        "frequency_penalty": {
+          "type": "number"
+        },
+        "presence_penalty": {
+          "type": "number"
+        },
+        "provider_options": {
+          "type": "object"
+        }
+      },
+      "additionalProperties": false,
+      "type": "object"
+    },
     "Options": {
       "properties": {
         "context_paths": {
@@ -442,6 +472,10 @@
           "type": "object",
           "description": "Additional fields to include in request bodies"
         },
+        "provider_options": {
+          "type": "object",
+          "description": "Additional provider-specific options for this provider"
+        },
         "models": {
           "items": {
             "$ref": "#/$defs/Model"
@@ -478,6 +512,10 @@
           ],
           "description": "Reasoning effort level for OpenAI models that support it"
         },
+        "think": {
+          "type": "boolean",
+          "description": "Enable thinking mode for Anthropic models that support reasoning"
+        },
         "max_tokens": {
           "type": "integer",
           "maximum": 200000,
@@ -487,9 +525,39 @@
             4096
           ]
         },
-        "think": {
-          "type": "boolean",
-          "description": "Enable thinking mode for Anthropic models that support reasoning"
+        "temperature": {
+          "type": "number",
+          "maximum": 1,
+          "minimum": 0,
+          "description": "Sampling temperature",
+          "examples": [
+            0.7
+          ]
+        },
+        "top_p": {
+          "type": "number",
+          "maximum": 1,
+          "minimum": 0,
+          "description": "Top-p (nucleus) sampling parameter",
+          "examples": [
+            0.9
+          ]
+        },
+        "top_k": {
+          "type": "integer",
+          "description": "Top-k sampling parameter"
+        },
+        "frequency_penalty": {
+          "type": "number",
+          "description": "Frequency penalty to reduce repetition"
+        },
+        "presence_penalty": {
+          "type": "number",
+          "description": "Presence penalty to increase topic diversity"
+        },
+        "provider_options": {
+          "type": "object",
+          "description": "Additional provider-specific options for the model"
         }
       },
       "additionalProperties": false,



Test patch:
diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
new file mode 100644
index 00000000..527552e2
--- /dev/null
+++ b/internal/agent/agent_test.go
@@ -0,0 +1,619 @@
+package agent
+
+import (
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/shell"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
+
+	_ "github.com/joho/godotenv/autoload"
+)
+
+var modelPairs = []modelPair{
+	{"anthropic-sonnet", anthropicBuilder("claude-sonnet-4-5-20250929"), anthropicBuilder("claude-3-5-haiku-20241022")},
+	{"openai-gpt-5", openaiBuilder("gpt-5"), openaiBuilder("gpt-4o")},
+	{"openrouter-kimi-k2", openRouterBuilder("moonshotai/kimi-k2-0905"), openRouterBuilder("qwen/qwen3-next-80b-a3b-instruct")},
+	{"zai-glm4.6", zAIBuilder("glm-4.6"), zAIBuilder("glm-4.5-air")},
+}
+
+func getModels(t *testing.T, r *recorder.Recorder, pair modelPair) (fantasy.LanguageModel, fantasy.LanguageModel) {
+	large, err := pair.largeModel(t, r)
+	require.NoError(t, err)
+	small, err := pair.smallModel(t, r)
+	require.NoError(t, err)
+	return large, small
+}
+
+func setupAgent(t *testing.T, pair modelPair) (SessionAgent, fakeEnv) {
+	r := newRecorder(t)
+	large, small := getModels(t, r, pair)
+	env := testEnv(t)
+
+	createSimpleGoProject(t, env.workingDir)
+	agent, err := coderAgent(r, env, large, small)
+	shell.Reset(env.workingDir)
+	require.NoError(t, err)
+	return agent, env
+}
+
+func TestCoderAgent(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("skipping on windows for now")
+	}
+
+	for _, pair := range modelPairs {
+		t.Run(pair.name, func(t *testing.T) {
+			t.Run("simple test", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "Hello",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+				// Should have the agent and user message
+				assert.Equal(t, len(msgs), 2)
+			})
+			t.Run("read a file", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "Read the go mod",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+				foundFile := false
+				var tcID string
+			out:
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.ViewToolName {
+								tcID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == tcID {
+								if strings.Contains(tr.Content, "module example.com/testproject") {
+									foundFile = true
+									break out
+								}
+							}
+						}
+					}
+				}
+				require.True(t, foundFile)
+			})
+			t.Run("update a file", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "update the main.go file by changing the print to say hello from crush",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundRead := false
+				foundWrite := false
+				var readTCID, writeTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.ViewToolName {
+								readTCID = tc.ID
+							}
+							if tc.Name == tools.EditToolName || tc.Name == tools.WriteToolName {
+								writeTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == readTCID {
+								foundRead = true
+							}
+							if tr.ToolCallID == writeTCID {
+								foundWrite = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundRead, "Expected to find a read operation")
+				require.True(t, foundWrite, "Expected to find a write operation")
+
+				mainGoPath := filepath.Join(env.workingDir, "main.go")
+				content, err := os.ReadFile(mainGoPath)
+				require.NoError(t, err)
+				require.Contains(t, strings.ToLower(string(content)), "hello from crush")
+			})
+			t.Run("bash tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use bash to create a file named test.txt with content 'hello bash'. do not print its timestamp",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundBash := false
+				var bashTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.BashToolName {
+								bashTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == bashTCID {
+								foundBash = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundBash, "Expected to find a bash operation")
+
+				testFilePath := filepath.Join(env.workingDir, "test.txt")
+				content, err := os.ReadFile(testFilePath)
+				require.NoError(t, err)
+				require.Contains(t, string(content), "hello bash")
+			})
+			t.Run("download tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "download the file from https://example-files.online-convert.com/document/txt/example.txt and save it as example.txt",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundDownload := false
+				var downloadTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.DownloadToolName {
+								downloadTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == downloadTCID {
+								foundDownload = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundDownload, "Expected to find a download operation")
+
+				examplePath := filepath.Join(env.workingDir, "example.txt")
+				_, err = os.Stat(examplePath)
+				require.NoError(t, err, "Expected example.txt file to exist")
+			})
+			t.Run("fetch tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "fetch the content from https://example-files.online-convert.com/website/html/example.html and tell me if it contains the word 'John Doe'",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundFetch := false
+				var fetchTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.FetchToolName {
+								fetchTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == fetchTCID {
+								foundFetch = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundFetch, "Expected to find a fetch operation")
+			})
+			t.Run("glob tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use glob to find all .go files in the current directory",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundGlob := false
+				var globTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.GlobToolName {
+								globTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == globTCID {
+								foundGlob = true
+								require.Contains(t, tr.Content, "main.go", "Expected glob to find main.go")
+							}
+						}
+					}
+				}
+
+				require.True(t, foundGlob, "Expected to find a glob operation")
+			})
+			t.Run("grep tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use grep to search for the word 'package' in go files",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundGrep := false
+				var grepTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.GrepToolName {
+								grepTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == grepTCID {
+								foundGrep = true
+								require.Contains(t, tr.Content, "main.go", "Expected grep to find main.go")
+							}
+						}
+					}
+				}
+
+				require.True(t, foundGrep, "Expected to find a grep operation")
+			})
+			t.Run("ls tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use ls to list the files in the current directory",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundLS := false
+				var lsTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.LSToolName {
+								lsTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == lsTCID {
+								foundLS = true
+								require.Contains(t, tr.Content, "main.go", "Expected ls to list main.go")
+								require.Contains(t, tr.Content, "go.mod", "Expected ls to list go.mod")
+							}
+						}
+					}
+				}
+
+				require.True(t, foundLS, "Expected to find an ls operation")
+			})
+			t.Run("multiedit tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use multiedit to change 'Hello, World!' to 'Hello, Crush!' and add a comment '// Greeting' above the fmt.Println line in main.go",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundMultiEdit := false
+				var multiEditTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.MultiEditToolName {
+								multiEditTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == multiEditTCID {
+								foundMultiEdit = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundMultiEdit, "Expected to find a multiedit operation")
+
+				mainGoPath := filepath.Join(env.workingDir, "main.go")
+				content, err := os.ReadFile(mainGoPath)
+				require.NoError(t, err)
+				require.Contains(t, string(content), "Hello, Crush!", "Expected file to contain 'Hello, Crush!'")
+			})
+			t.Run("sourcegraph tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use sourcegraph to search for 'func main' in Go repositories",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundSourcegraph := false
+				var sourcegraphTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.SourcegraphToolName {
+								sourcegraphTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == sourcegraphTCID {
+								foundSourcegraph = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundSourcegraph, "Expected to find a sourcegraph operation")
+			})
+			t.Run("write tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use write to create a new file called config.json with content '{\"name\": \"test\", \"version\": \"1.0.0\"}'",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundWrite := false
+				var writeTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.WriteToolName {
+								writeTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == writeTCID {
+								foundWrite = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundWrite, "Expected to find a write operation")
+
+				configPath := filepath.Join(env.workingDir, "config.json")
+				content, err := os.ReadFile(configPath)
+				require.NoError(t, err)
+				require.Contains(t, string(content), "test", "Expected config.json to contain 'test'")
+				require.Contains(t, string(content), "1.0.0", "Expected config.json to contain '1.0.0'")
+			})
+			t.Run("parallel tool calls", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use glob to find all .go files and use ls to list the current directory, it is very important that you run both tool calls in parallel",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				var assistantMsg *message.Message
+				var toolMsgs []message.Message
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant && len(msg.ToolCalls()) > 0 {
+						assistantMsg = &msg
+					}
+					if msg.Role == message.Tool {
+						toolMsgs = append(toolMsgs, msg)
+					}
+				}
+
+				require.NotNil(t, assistantMsg, "Expected to find an assistant message with tool calls")
+				require.NotNil(t, toolMsgs, "Expected to find a tool message")
+
+				toolCalls := assistantMsg.ToolCalls()
+				require.GreaterOrEqual(t, len(toolCalls), 2, "Expected at least 2 tool calls in parallel")
+
+				foundGlob := false
+				foundLS := false
+				var globTCID, lsTCID string
+
+				for _, tc := range toolCalls {
+					if tc.Name == tools.GlobToolName {
+						foundGlob = true
+						globTCID = tc.ID
+					}
+					if tc.Name == tools.LSToolName {
+						foundLS = true
+						lsTCID = tc.ID
+					}
+				}
+
+				require.True(t, foundGlob, "Expected to find a glob tool call")
+				require.True(t, foundLS, "Expected to find an ls tool call")
+
+				require.GreaterOrEqual(t, len(toolMsgs), 2, "Expected at least 2 tool results in the same message")
+
+				foundGlobResult := false
+				foundLSResult := false
+
+				for _, msg := range toolMsgs {
+					for _, tr := range msg.ToolResults() {
+						if tr.ToolCallID == globTCID {
+							foundGlobResult = true
+							require.Contains(t, tr.Content, "main.go", "Expected glob result to contain main.go")
+							require.False(t, tr.IsError, "Expected glob result to not be an error")
+						}
+						if tr.ToolCallID == lsTCID {
+							foundLSResult = true
+							require.Contains(t, tr.Content, "main.go", "Expected ls result to contain main.go")
+							require.False(t, tr.IsError, "Expected ls result to not be an error")
+						}
+					}
+				}
+
+				require.True(t, foundGlobResult, "Expected to find glob tool result")
+				require.True(t, foundLSResult, "Expected to find ls tool result")
+			})
+		})
+	}
+}
diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go
new file mode 100644
index 00000000..b668b5db
--- /dev/null
+++ b/internal/agent/common_test.go
@@ -0,0 +1,215 @@
+package agent
+
+import (
+	"context"
+	"net/http"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"charm.land/fantasy"
+	"charm.land/fantasy/providers/anthropic"
+	"charm.land/fantasy/providers/openai"
+	"charm.land/fantasy/providers/openaicompat"
+	"charm.land/fantasy/providers/openrouter"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/agent/prompt"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
+
+	_ "github.com/joho/godotenv/autoload"
+)
+
+// fakeEnv is an environment for testing.
+type fakeEnv struct {
+	workingDir  string
+	sessions    session.Service
+	messages    message.Service
+	permissions permission.Service
+	history     history.Service
+	lspClients  *csync.Map[string, *lsp.Client]
+}
+
+type builderFunc func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error)
+
+type modelPair struct {
+	name       string
+	largeModel builderFunc
+	smallModel builderFunc
+}
+
+func anthropicBuilder(model string) builderFunc {
+	return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) {
+		provider, err := anthropic.New(
+			anthropic.WithAPIKey(os.Getenv("CRUSH_ANTHROPIC_API_KEY")),
+			anthropic.WithHTTPClient(&http.Client{Transport: r}),
+		)
+		if err != nil {
+			return nil, err
+		}
+		return provider.LanguageModel(t.Context(), model)
+	}
+}
+
+func openaiBuilder(model string) builderFunc {
+	return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) {
+		provider, err := openai.New(
+			openai.WithAPIKey(os.Getenv("CRUSH_OPENAI_API_KEY")),
+			openai.WithHTTPClient(&http.Client{Transport: r}),
+		)
+		if err != nil {
+			return nil, err
+		}
+		return provider.LanguageModel(t.Context(), model)
+	}
+}
+
+func openRouterBuilder(model string) builderFunc {
+	return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) {
+		provider, err := openrouter.New(
+			openrouter.WithAPIKey(os.Getenv("CRUSH_OPENROUTER_API_KEY")),
+			openrouter.WithHTTPClient(&http.Client{Transport: r}),
+		)
+		if err != nil {
+			return nil, err
+		}
+		return provider.LanguageModel(t.Context(), model)
+	}
+}
+
+func zAIBuilder(model string) builderFunc {
+	return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) {
+		provider, err := openaicompat.New(
+			openaicompat.WithBaseURL("https://api.z.ai/api/coding/paas/v4"),
+			openaicompat.WithAPIKey(os.Getenv("CRUSH_ZAI_API_KEY")),
+			openaicompat.WithHTTPClient(&http.Client{Transport: r}),
+		)
+		if err != nil {
+			return nil, err
+		}
+		return provider.LanguageModel(t.Context(), model)
+	}
+}
+
+func testEnv(t *testing.T) fakeEnv {
+	workingDir := filepath.Join("/tmp/crush-test/", t.Name())
+	os.RemoveAll(workingDir)
+
+	err := os.MkdirAll(workingDir, 0o755)
+	require.NoError(t, err)
+
+	conn, err := db.Connect(t.Context(), t.TempDir())
+	require.NoError(t, err)
+
+	q := db.New(conn)
+	sessions := session.NewService(q)
+	messages := message.NewService(q)
+
+	permissions := permission.NewPermissionService(workingDir, true, []string{})
+	history := history.NewService(q, conn)
+	lspClients := csync.NewMap[string, *lsp.Client]()
+
+	t.Cleanup(func() {
+		conn.Close()
+		os.RemoveAll(workingDir)
+	})
+
+	return fakeEnv{
+		workingDir,
+		sessions,
+		messages,
+		permissions,
+		history,
+		lspClients,
+	}
+}
+
+func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPrompt string, tools ...fantasy.AgentTool) SessionAgent {
+	largeModel := Model{
+		Model: large,
+		CatwalkCfg: catwalk.Model{
+			ContextWindow:    200000,
+			DefaultMaxTokens: 10000,
+		},
+	}
+	smallModel := Model{
+		Model: small,
+		CatwalkCfg: catwalk.Model{
+			ContextWindow:    200000,
+			DefaultMaxTokens: 10000,
+		},
+	}
+	agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, true, env.sessions, env.messages, tools})
+	return agent
+}
+
+func coderAgent(r *recorder.Recorder, env fakeEnv, large, small fantasy.LanguageModel) (SessionAgent, error) {
+	fixedTime := func() time.Time {
+		t, _ := time.Parse("1/2/2006", "1/1/2025")
+		return t
+	}
+	prompt, err := coderPrompt(
+		prompt.WithTimeFunc(fixedTime),
+		prompt.WithPlatform("linux"),
+		prompt.WithWorkingDir(filepath.ToSlash(env.workingDir)),
+	)
+	if err != nil {
+		return nil, err
+	}
+	cfg, err := config.Init(env.workingDir, "", false)
+	if err != nil {
+		return nil, err
+	}
+
+	systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
+	if err != nil {
+		return nil, err
+	}
+	allTools := []fantasy.AgentTool{
+		tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution),
+		tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
+		tools.NewEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
+		tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
+		tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
+		tools.NewGlobTool(env.workingDir),
+		tools.NewGrepTool(env.workingDir),
+		tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
+		tools.NewSourcegraphTool(r.GetDefaultClient()),
+		tools.NewViewTool(env.lspClients, env.permissions, env.workingDir),
+		tools.NewWriteTool(env.lspClients, env.permissions, env.history, env.workingDir),
+	}
+
+	return testSessionAgent(env, large, small, systemPrompt, allTools...), nil
+}
+
+// createSimpleGoProject creates a simple Go project structure in the given directory.
+// It creates a go.mod file and a main.go file with a basic hello world program.
+func createSimpleGoProject(t *testing.T, dir string) {
+	goMod := `module example.com/testproject
+
+go 1.23
+`
+	err := os.WriteFile(dir+"/go.mod", []byte(goMod), 0o644)
+	require.NoError(t, err)
+
+	mainGo := `package main
+
+import "fmt"
+
+func main() {
+	fmt.Println("Hello, World!")
+}
+`
+	err = os.WriteFile(dir+"/main.go", []byte(mainGo), 0o644)
+	require.NoError(t, err)
+}
diff --git a/internal/agent/recorder_test.go b/internal/agent/recorder_test.go
new file mode 100644
index 00000000..34ac8f5a
--- /dev/null
+++ b/internal/agent/recorder_test.go
@@ -0,0 +1,116 @@
+package agent
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"path/filepath"
+	"reflect"
+	"strings"
+	"testing"
+
+	"go.yaml.in/yaml/v4"
+	"gopkg.in/dnaeon/go-vcr.v4/pkg/cassette"
+	"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
+)
+
+func newRecorder(t *testing.T) *recorder.Recorder {
+	cassetteName := filepath.Join("testdata", t.Name())
+
+	r, err := recorder.New(
+		cassetteName,
+		recorder.WithMode(recorder.ModeRecordOnce),
+		recorder.WithMatcher(customMatcher(t)),
+		recorder.WithMarshalFunc(marshalFunc),
+		recorder.WithSkipRequestLatency(true), // disable sleep to simulate response time, makes tests faster
+		recorder.WithHook(hookRemoveHeaders, recorder.AfterCaptureHook),
+	)
+	if err != nil {
+		t.Fatalf("recorder: failed to create recorder: %v", err)
+	}
+
+	t.Cleanup(func() {
+		if err := r.Stop(); err != nil {
+			t.Errorf("recorder: failed to stop recorder: %v", err)
+		}
+	})
+
+	return r
+}
+
+func customMatcher(t *testing.T) recorder.MatcherFunc {
+	return func(r *http.Request, i cassette.Request) bool {
+		if r.Body == nil || r.Body == http.NoBody {
+			return cassette.DefaultMatcher(r, i)
+		}
+		if r.Method != i.Method || r.URL.String() != i.URL {
+			return false
+		}
+
+		reqBody, err := io.ReadAll(r.Body)
+		if err != nil {
+			t.Fatalf("recorder: failed to read request body")
+		}
+		r.Body.Close()
+		r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
+
+		// Some providers can sometimes generate JSON requests with keys in
+		// a different order, which means a direct string comparison will fail.
+		// Falling back to deserializing the content if we don't have a match.
+		requestContent := normalizeLineEndings(reqBody)
+		cassetteContent := normalizeLineEndings(i.Body)
+		if requestContent == cassetteContent {
+			return true
+		}
+		var content1, content2 any
+		if err := json.Unmarshal([]byte(requestContent), &content1); err != nil {
+			return false
+		}
+		if err := json.Unmarshal([]byte(cassetteContent), &content2); err != nil {
+			return false
+		}
+		return reflect.DeepEqual(content1, content2)
+	}
+}
+
+func marshalFunc(in any) ([]byte, error) {
+	var buff bytes.Buffer
+	enc := yaml.NewEncoder(&buff)
+	enc.SetIndent(2)
+	enc.CompactSeqIndent()
+	if err := enc.Encode(in); err != nil {
+		return nil, err
+	}
+	return buff.Bytes(), nil
+}
+
+var headersToKeep = map[string]struct{}{
+	"accept":       {},
+	"content-type": {},
+	"user-agent":   {},
+}
+
+func hookRemoveHeaders(i *cassette.Interaction) error {
+	for k := range i.Request.Headers {
+		if _, ok := headersToKeep[strings.ToLower(k)]; !ok {
+			delete(i.Request.Headers, k)
+		}
+	}
+	for k := range i.Response.Headers {
+		if _, ok := headersToKeep[strings.ToLower(k)]; !ok {
+			delete(i.Response.Headers, k)
+		}
+	}
+	return nil
+}
+
+// normalizeLineEndings does not only replace `\r\n` into `\n`,
+// but also replaces `\\r\\n` into `\\n`. That's because we want the content
+// inside JSON string to be replaces as well.
+func normalizeLineEndings[T string | []byte](s T) string {
+	str := string(s)
+	str = strings.ReplaceAll(str, "\r\n", "\n")
+	str = strings.ReplaceAll(str, `\r\n`, `\n`)
+	return str
+}
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml
new file mode 100644
index 00000000..4a24da8e
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/download_tool.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/download_tool.yaml
new file mode 100644
index 00000000..3cfcd84f
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/download_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/fetch_tool.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/fetch_tool.yaml
new file mode 100644
index 00000000..95fc1ec1
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/fetch_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/glob_tool.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/glob_tool.yaml
new file mode 100644
index 00000000..e4af45ef
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/glob_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/grep_tool.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/grep_tool.yaml
new file mode 100644
index 00000000..0ec8edde
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/grep_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/ls_tool.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/ls_tool.yaml
new file mode 100644
index 00000000..bfd8c37c
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/ls_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/multiedit_tool.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/multiedit_tool.yaml
new file mode 100644
index 00000000..fc0a8e48
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/multiedit_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/parallel_tool_calls.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/parallel_tool_calls.yaml
new file mode 100644
index 00000000..d74d53c2
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/parallel_tool_calls.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/read_a_file.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/read_a_file.yaml
new file mode 100644
index 00000000..f1b7f35c
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/read_a_file.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/simple_test.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/simple_test.yaml
new file mode 100644
index 00000000..e015bc8b
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/simple_test.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/sourcegraph_tool.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/sourcegraph_tool.yaml
new file mode 100644
index 00000000..d1c19661
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/sourcegraph_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/update_a_file.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/update_a_file.yaml
new file mode 100644
index 00000000..b98fbf34
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/update_a_file.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/write_tool.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/write_tool.yaml
new file mode 100644
index 00000000..ed80db69
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/write_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/bash_tool.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/bash_tool.yaml
new file mode 100644
index 00000000..808fdeaf
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/bash_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/download_tool.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/download_tool.yaml
new file mode 100644
index 00000000..8e4460a9
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/download_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/fetch_tool.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/fetch_tool.yaml
new file mode 100644
index 00000000..b3150950
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/fetch_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/glob_tool.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/glob_tool.yaml
new file mode 100644
index 00000000..0d6a828f
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/glob_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/grep_tool.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/grep_tool.yaml
new file mode 100644
index 00000000..6b3601e5
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/grep_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/ls_tool.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/ls_tool.yaml
new file mode 100644
index 00000000..d993e0e8
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/ls_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/multiedit_tool.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/multiedit_tool.yaml
new file mode 100644
index 00000000..53f93fbd
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/multiedit_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/parallel_tool_calls.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/parallel_tool_calls.yaml
new file mode 100644
index 00000000..0b7feedf
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/parallel_tool_calls.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/read_a_file.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/read_a_file.yaml
new file mode 100644
index 00000000..fa83a07e
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/read_a_file.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/simple_test.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/simple_test.yaml
new file mode 100644
index 00000000..db7c17a4
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/simple_test.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/sourcegraph_tool.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/sourcegraph_tool.yaml
new file mode 100644
index 00000000..5448ca51
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/sourcegraph_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/update_a_file.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/update_a_file.yaml
new file mode 100644
index 00000000..8f02f069
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/update_a_file.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/write_tool.yaml b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/write_tool.yaml
new file mode 100644
index 00000000..b61a0616
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openai-gpt-5/write_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/bash_tool.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/bash_tool.yaml
new file mode 100644
index 00000000..745fdeed
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/bash_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/download_tool.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/download_tool.yaml
new file mode 100644
index 00000000..b558d752
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/download_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/fetch_tool.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/fetch_tool.yaml
new file mode 100644
index 00000000..a70f32a4
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/fetch_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/glob_tool.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/glob_tool.yaml
new file mode 100644
index 00000000..0923c061
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/glob_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/grep_tool.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/grep_tool.yaml
new file mode 100644
index 00000000..e4a0a52b
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/grep_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/ls_tool.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/ls_tool.yaml
new file mode 100644
index 00000000..2b38ec1f
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/ls_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/multiedit_tool.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/multiedit_tool.yaml
new file mode 100644
index 00000000..bb527299
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/multiedit_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/parallel_tool_calls.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/parallel_tool_calls.yaml
new file mode 100644
index 00000000..c66229cf
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/parallel_tool_calls.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/read_a_file.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/read_a_file.yaml
new file mode 100644
index 00000000..5e397b92
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/read_a_file.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/simple_test.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/simple_test.yaml
new file mode 100644
index 00000000..79aa27f5
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/simple_test.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/sourcegraph_tool.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/sourcegraph_tool.yaml
new file mode 100644
index 00000000..18a78a05
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/sourcegraph_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/update_a_file.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/update_a_file.yaml
new file mode 100644
index 00000000..97416f64
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/update_a_file.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/write_tool.yaml b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/write_tool.yaml
new file mode 100644
index 00000000..c93edaaa
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/write_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/bash_tool.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/bash_tool.yaml
new file mode 100644
index 00000000..7cf082af
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/bash_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/download_tool.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/download_tool.yaml
new file mode 100644
index 00000000..90c50140
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/download_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/fetch_tool.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/fetch_tool.yaml
new file mode 100644
index 00000000..e83901d3
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/fetch_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/glob_tool.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/glob_tool.yaml
new file mode 100644
index 00000000..f4b028ba
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/glob_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/grep_tool.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/grep_tool.yaml
new file mode 100644
index 00000000..820aa432
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/grep_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/ls_tool.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/ls_tool.yaml
new file mode 100644
index 00000000..d756b16e
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/ls_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/multiedit_tool.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/multiedit_tool.yaml
new file mode 100644
index 00000000..a18091c1
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/multiedit_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/parallel_tool_calls.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/parallel_tool_calls.yaml
new file mode 100644
index 00000000..8177457a
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/parallel_tool_calls.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/read_a_file.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/read_a_file.yaml
new file mode 100644
index 00000000..ea773f51
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/read_a_file.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/simple_test.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/simple_test.yaml
new file mode 100644
index 00000000..f29075c6
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/simple_test.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/sourcegraph_tool.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/sourcegraph_tool.yaml
new file mode 100644
index 00000000..188d3ab9
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/sourcegraph_tool.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/update_a_file.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/update_a_file.yaml
new file mode 100644
index 00000000..43ab1c37
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/update_a_file.yaml differ
diff --git a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/write_tool.yaml b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/write_tool.yaml
new file mode 100644
index 00000000..7fe58b3e
Binary files /dev/null and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/write_tool.yaml differ
diff --git a/internal/llm/tools/grep_test.go b/internal/agent/tools/grep_test.go
similarity index 100%
rename from internal/llm/tools/grep_test.go
rename to internal/agent/tools/grep_test.go
diff --git a/internal/agent/tools/multiedit_test.go b/internal/agent/tools/multiedit_test.go
new file mode 100644
index 00000000..98cf6139
--- /dev/null
+++ b/internal/agent/tools/multiedit_test.go
@@ -0,0 +1,225 @@
+package tools
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/stretchr/testify/require"
+)
+
+type mockPermissionService struct {
+	*pubsub.Broker[permission.PermissionRequest]
+}
+
+func (m *mockPermissionService) Request(req permission.CreatePermissionRequest) bool {
+	return true
+}
+
+func (m *mockPermissionService) Grant(req permission.PermissionRequest) {}
+
+func (m *mockPermissionService) Deny(req permission.PermissionRequest) {}
+
+func (m *mockPermissionService) GrantPersistent(req permission.PermissionRequest) {}
+
+func (m *mockPermissionService) AutoApproveSession(sessionID string) {}
+
+func (m *mockPermissionService) SetSkipRequests(skip bool) {}
+
+func (m *mockPermissionService) SkipRequests() bool {
+	return false
+}
+
+func (m *mockPermissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[permission.PermissionNotification] {
+	return make(<-chan pubsub.Event[permission.PermissionNotification])
+}
+
+type mockHistoryService struct {
+	*pubsub.Broker[history.File]
+}
+
+func (m *mockHistoryService) Create(ctx context.Context, sessionID, path, content string) (history.File, error) {
+	return history.File{Path: path, Content: content}, nil
+}
+
+func (m *mockHistoryService) CreateVersion(ctx context.Context, sessionID, path, content string) (history.File, error) {
+	return history.File{}, nil
+}
+
+func (m *mockHistoryService) GetByPathAndSession(ctx context.Context, path, sessionID string) (history.File, error) {
+	return history.File{Path: path, Content: ""}, nil
+}
+
+func (m *mockHistoryService) Get(ctx context.Context, id string) (history.File, error) {
+	return history.File{}, nil
+}
+
+func (m *mockHistoryService) ListBySession(ctx context.Context, sessionID string) ([]history.File, error) {
+	return nil, nil
+}
+
+func (m *mockHistoryService) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]history.File, error) {
+	return nil, nil
+}
+
+func (m *mockHistoryService) Delete(ctx context.Context, id string) error {
+	return nil
+}
+
+func (m *mockHistoryService) DeleteSessionFiles(ctx context.Context, sessionID string) error {
+	return nil
+}
+
+func TestApplyEditToContentPartialSuccess(t *testing.T) {
+	t.Parallel()
+
+	content := "line 1\nline 2\nline 3\n"
+
+	// Test successful edit.
+	newContent, err := applyEditToContent(content, MultiEditOperation{
+		OldString: "line 1",
+		NewString: "LINE 1",
+	})
+	require.NoError(t, err)
+	require.Contains(t, newContent, "LINE 1")
+	require.Contains(t, newContent, "line 2")
+
+	// Test failed edit (string not found).
+	_, err = applyEditToContent(content, MultiEditOperation{
+		OldString: "line 99",
+		NewString: "LINE 99",
+	})
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "not found")
+}
+
+func TestMultiEditSequentialApplication(t *testing.T) {
+	t.Parallel()
+
+	tmpDir := t.TempDir()
+	testFile := filepath.Join(tmpDir, "test.txt")
+
+	// Create test file.
+	content := "line 1\nline 2\nline 3\nline 4\n"
+	err := os.WriteFile(testFile, []byte(content), 0o644)
+	require.NoError(t, err)
+
+	// Mock components.
+	lspClients := csync.NewMap[string, *lsp.Client]()
+	permissions := &mockPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
+	files := &mockHistoryService{Broker: pubsub.NewBroker[history.File]()}
+
+	// Create multiedit tool.
+	_ = NewMultiEditTool(lspClients, permissions, files, tmpDir)
+
+	// Simulate reading the file first.
+	recordFileRead(testFile)
+
+	// Manually test the sequential application logic.
+	currentContent := content
+
+	// Apply edits sequentially, tracking failures.
+	edits := []MultiEditOperation{
+		{OldString: "line 1", NewString: "LINE 1"},   // Should succeed
+		{OldString: "line 99", NewString: "LINE 99"}, // Should fail - doesn't exist
+		{OldString: "line 3", NewString: "LINE 3"},   // Should succeed
+		{OldString: "line 2", NewString: "LINE 2"},   // Should succeed - still exists
+	}
+
+	var failedEdits []FailedEdit
+	successCount := 0
+
+	for i, edit := range edits {
+		newContent, err := applyEditToContent(currentContent, edit)
+		if err != nil {
+			failedEdits = append(failedEdits, FailedEdit{
+				Index: i + 1,
+				Error: err.Error(),
+				Edit:  edit,
+			})
+			continue
+		}
+		currentContent = newContent
+		successCount++
+	}
+
+	// Verify results.
+	require.Equal(t, 3, successCount, "Expected 3 successful edits")
+	require.Len(t, failedEdits, 1, "Expected 1 failed edit")
+
+	// Check failed edit details.
+	require.Equal(t, 2, failedEdits[0].Index)
+	require.Contains(t, failedEdits[0].Error, "not found")
+
+	// Verify content changes.
+	require.Contains(t, currentContent, "LINE 1")
+	require.Contains(t, currentContent, "LINE 2")
+	require.Contains(t, currentContent, "LINE 3")
+	require.Contains(t, currentContent, "line 4") // Original unchanged
+	require.NotContains(t, currentContent, "LINE 99")
+}
+
+func TestMultiEditAllEditsSucceed(t *testing.T) {
+	t.Parallel()
+
+	content := "line 1\nline 2\nline 3\n"
+
+	edits := []MultiEditOperation{
+		{OldString: "line 1", NewString: "LINE 1"},
+		{OldString: "line 2", NewString: "LINE 2"},
+		{OldString: "line 3", NewString: "LINE 3"},
+	}
+
+	currentContent := content
+	successCount := 0
+
+	for _, edit := range edits {
+		newContent, err := applyEditToContent(currentContent, edit)
+		if err != nil {
+			t.Fatalf("Unexpected error: %v", err)
+		}
+		currentContent = newContent
+		successCount++
+	}
+
+	require.Equal(t, 3, successCount)
+	require.Contains(t, currentContent, "LINE 1")
+	require.Contains(t, currentContent, "LINE 2")
+	require.Contains(t, currentContent, "LINE 3")
+}
+
+func TestMultiEditAllEditsFail(t *testing.T) {
+	t.Parallel()
+
+	content := "line 1\nline 2\n"
+
+	edits := []MultiEditOperation{
+		{OldString: "line 99", NewString: "LINE 99"},
+		{OldString: "line 100", NewString: "LINE 100"},
+	}
+
+	currentContent := content
+	var failedEdits []FailedEdit
+
+	for i, edit := range edits {
+		newContent, err := applyEditToContent(currentContent, edit)
+		if err != nil {
+			failedEdits = append(failedEdits, FailedEdit{
+				Index: i + 1,
+				Error: err.Error(),
+				Edit:  edit,
+			})
+			continue
+		}
+		currentContent = newContent
+	}
+
+	require.Len(t, failedEdits, 2)
+	require.Equal(t, content, currentContent, "Content should be unchanged")
+}
diff --git a/internal/llm/tools/testdata/grep.txt b/internal/agent/tools/testdata/grep.txt
similarity index 100%
rename from internal/llm/tools/testdata/grep.txt
rename to internal/agent/tools/testdata/grep.txt
diff --git a/internal/config/load_test.go b/internal/config/load_test.go
index 406fe07d..06e4e664 100644
--- a/internal/config/load_test.go
+++ b/internal/config/load_test.go
@@ -462,11 +462,11 @@ func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
 	}
 
 	cfg.SetupAgents()
-	coderAgent, ok := cfg.Agents["coder"]
+	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
 	assert.Equal(t, allToolNames(), coderAgent.AllowedTools)
 
-	taskAgent, ok := cfg.Agents["task"]
+	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
 	assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 }
@@ -483,11 +483,11 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 	}
 
 	cfg.SetupAgents()
-	coderAgent, ok := cfg.Agents["coder"]
+	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
-	assert.Equal(t, []string{"agent", "bash", "multiedit", "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["task"]
+	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
 	assert.Equal(t, []string{"glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 }
@@ -506,11 +506,11 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 	}
 
 	cfg.SetupAgents()
-	coderAgent, ok := cfg.Agents["coder"]
+	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
-	assert.Equal(t, []string{"agent", "bash", "download", "edit", "multiedit", "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["task"]
+	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
 	assert.Equal(t, []string{}, taskAgent.AllowedTools)
 }
diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go
index 8b499919..1262b60e 100644
--- a/internal/config/provider_test.go
+++ b/internal/config/provider_test.go
@@ -57,7 +57,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) {
 	if err != nil {
 		t.Fatalf("Failed to write old providers to file: %v", err)
 	}
-	providers, err := loadProviders(false, client, tmpPath)
+	providers, err := loadProviders(true, client, tmpPath)
 	require.NoError(t, err)
 	require.NotNil(t, providers)
 	require.Len(t, providers, 1)
diff --git a/internal/llm/prompt/prompt_test.go b/internal/llm/prompt/prompt_test.go
deleted file mode 100644
index 66f9d438..00000000
--- a/internal/llm/prompt/prompt_test.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package prompt
-
-import (
-	"os"
-	"strings"
-	"testing"
-
-	"github.com/charmbracelet/crush/internal/home"
-)
-
-func TestExpandPath(t *testing.T) {
-	tests := []struct {
-		name     string
-		input    string
-		expected func() string
-	}{
-		{
-			name:  "regular path unchanged",
-			input: "/absolute/path",
-			expected: func() string {
-				return "/absolute/path"
-			},
-		},
-		{
-			name:  "tilde expansion",
-			input: "~/documents",
-			expected: func() string {
-				return home.Dir() + "/documents"
-			},
-		},
-		{
-			name:  "tilde only",
-			input: "~",
-			expected: func() string {
-				return home.Dir()
-			},
-		},
-		{
-			name:  "environment variable expansion",
-			input: "$HOME",
-			expected: func() string {
-				return os.Getenv("HOME")
-			},
-		},
-		{
-			name:  "relative path unchanged",
-			input: "relative/path",
-			expected: func() string {
-				return "relative/path"
-			},
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			result := expandPath(tt.input)
-			expected := tt.expected()
-
-			// Skip test if environment variable is not set
-			if strings.HasPrefix(tt.input, "$") && expected == "" {
-				t.Skip("Environment variable not set")
-			}
-
-			if result != expected {
-				t.Errorf("expandPath(%q) = %q, want %q", tt.input, result, expected)
-			}
-		})
-	}
-}
diff --git a/internal/llm/provider/openai_test.go b/internal/llm/provider/openai_test.go
deleted file mode 100644
index 52b0a20c..00000000
--- a/internal/llm/provider/openai_test.go
+++ /dev/null
@@ -1,166 +0,0 @@
-package provider
-
-import (
-	"context"
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"strings"
-	"testing"
-	"time"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/openai/openai-go"
-	"github.com/openai/openai-go/option"
-)
-
-func TestMain(m *testing.M) {
-	_, err := config.Init(".", "", true)
-	if err != nil {
-		panic("Failed to initialize config: " + err.Error())
-	}
-
-	os.Exit(m.Run())
-}
-
-func TestOpenAIClientStreamChoices(t *testing.T) {
-	// Create a mock server that returns Server-Sent Events with empty choices
-	// This simulates the 🤡 behavior when a server returns 200 instead of 404
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.Header().Set("Content-Type", "text/event-stream")
-		w.Header().Set("Cache-Control", "no-cache")
-		w.Header().Set("Connection", "keep-alive")
-		w.WriteHeader(http.StatusOK)
-
-		emptyChoicesChunk := map[string]any{
-			"id":      "chat-completion-test",
-			"object":  "chat.completion.chunk",
-			"created": time.Now().Unix(),
-			"model":   "test-model",
-			"choices": []any{}, // Empty choices array that causes panic
-		}
-
-		jsonData, _ := json.Marshal(emptyChoicesChunk)
-		w.Write([]byte("data: " + string(jsonData) + "\n\n"))
-		w.Write([]byte("data: [DONE]\n\n"))
-	}))
-	defer server.Close()
-
-	// Create OpenAI client pointing to our mock server
-	client := &openaiClient{
-		providerOptions: providerClientOptions{
-			modelType:     config.SelectedModelTypeLarge,
-			apiKey:        "test-key",
-			systemMessage: "test",
-			model: func(config.SelectedModelType) catwalk.Model {
-				return catwalk.Model{
-					ID:   "test-model",
-					Name: "test-model",
-				}
-			},
-		},
-		client: openai.NewClient(
-			option.WithAPIKey("test-key"),
-			option.WithBaseURL(server.URL),
-		),
-	}
-
-	// Create test messages
-	messages := []message.Message{
-		{
-			Role:  message.User,
-			Parts: []message.ContentPart{message.TextContent{Text: "Hello"}},
-		},
-	}
-
-	ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
-	defer cancel()
-
-	eventsChan := client.stream(ctx, messages, nil)
-
-	// Collect events - this will panic without the bounds check
-	for event := range eventsChan {
-		t.Logf("Received event: %+v", event)
-		if event.Type == EventError || event.Type == EventComplete {
-			break
-		}
-	}
-}
-
-func TestOpenAIClient429InsufficientQuotaError(t *testing.T) {
-	client := &openaiClient{
-		providerOptions: providerClientOptions{
-			modelType:     config.SelectedModelTypeLarge,
-			apiKey:        "test-key",
-			systemMessage: "test",
-			config: config.ProviderConfig{
-				ID:     "test-openai",
-				APIKey: "test-key",
-			},
-			model: func(config.SelectedModelType) catwalk.Model {
-				return catwalk.Model{
-					ID:   "test-model",
-					Name: "test-model",
-				}
-			},
-		},
-	}
-
-	// Test insufficient_quota error should not retry
-	apiErr := &openai.Error{
-		StatusCode: 429,
-		Message:    "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.",
-		Type:       "insufficient_quota",
-		Code:       "insufficient_quota",
-	}
-
-	retry, _, err := client.shouldRetry(1, apiErr)
-	if retry {
-		t.Error("Expected shouldRetry to return false for insufficient_quota error, but got true")
-	}
-	if err == nil {
-		t.Error("Expected shouldRetry to return an error for insufficient_quota, but got nil")
-	}
-	if err != nil && !strings.Contains(err.Error(), "quota") {
-		t.Errorf("Expected error message to mention quota, got: %v", err)
-	}
-}
-
-func TestOpenAIClient429RateLimitError(t *testing.T) {
-	client := &openaiClient{
-		providerOptions: providerClientOptions{
-			modelType:     config.SelectedModelTypeLarge,
-			apiKey:        "test-key",
-			systemMessage: "test",
-			config: config.ProviderConfig{
-				ID:     "test-openai",
-				APIKey: "test-key",
-			},
-			model: func(config.SelectedModelType) catwalk.Model {
-				return catwalk.Model{
-					ID:   "test-model",
-					Name: "test-model",
-				}
-			},
-		},
-	}
-
-	// Test regular rate limit error should retry
-	apiErr := &openai.Error{
-		StatusCode: 429,
-		Message:    "Rate limit reached for requests",
-		Type:       "rate_limit_exceeded",
-		Code:       "rate_limit_exceeded",
-	}
-
-	retry, _, err := client.shouldRetry(1, apiErr)
-	if !retry {
-		t.Error("Expected shouldRetry to return true for rate_limit_exceeded error, but got false")
-	}
-	if err != nil {
-		t.Errorf("Expected shouldRetry to return nil error for rate_limit_exceeded, but got: %v", err)
-	}
-}
diff --git a/internal/tui/exp/diffview/diffview_test.go b/internal/tui/exp/diffview/diffview_test.go
index c77c9e0e..de6eb301 100644
--- a/internal/tui/exp/diffview/diffview_test.go
+++ b/internal/tui/exp/diffview/diffview_test.go
@@ -249,8 +249,6 @@ func TestDiffViewHeight(t *testing.T) {
 
 					output := dv.String()
 					golden.RequireEqual(t, []byte(output))
-
-					assertHeight(t, height, output)
 				})
 			}
 		})
@@ -301,8 +299,6 @@ func TestDiffViewYOffset(t *testing.T) {
 
 					output := dv.String()
 					golden.RequireEqual(t, []byte(output))
-
-					assertHeight(t, 5, output)
 				})
 			}
 		})
@@ -328,8 +324,6 @@ func TestDiffViewYOffsetInfinite(t *testing.T) {
 
 					output := dv.String()
 					golden.RequireEqual(t, []byte(output))
-
-					assertHeight(t, 5, output)
 				})
 			}
 		})
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden
index d1d11780..9dfd2891 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden
@@ -12,5 +12,4 @@
 [48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m11[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;210;15;57;48;2;241;239;239mfunc[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;30;102;245;48;2;241;239;239mgetContent[m[38;2;76;79;105;48;2;241;239;239m()[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;210;15;57;48;2;241;239;239mstring[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;76;79;105;48;2;241;239;239m{[m[m[48;2;241;239;239m                      [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m12[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;210;15;57;48;2;241;239;239mfunc[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;30;102;245;48;2;241;239;239mgetContent[m[38;2;76;79;105;48;2;241;239;239m()[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;210;15;57;48;2;241;239;239mstring[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;76;79;105;48;2;241;239;239m{[m[m[48;2;241;239;239m                      [m
 [48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m12[m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m[38;2;76;79;105;48;2;255;235;238m    [m[38;2;136;57;239;48;2;255;235;238mreturn[m[38;2;76;79;105;48;2;255;235;238m [m[38;2;64;160;43;48;2;255;235;238m"Hello, world!"[m[m[48;2;255;235;238m                      [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden
index ce3d2e00..9dfd2891 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden
@@ -12,6 +12,4 @@
 [48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m11[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;210;15;57;48;2;241;239;239mfunc[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;30;102;245;48;2;241;239;239mgetContent[m[38;2;76;79;105;48;2;241;239;239m()[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;210;15;57;48;2;241;239;239mstring[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;76;79;105;48;2;241;239;239m{[m[m[48;2;241;239;239m                      [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m12[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;210;15;57;48;2;241;239;239mfunc[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;30;102;245;48;2;241;239;239mgetContent[m[38;2;76;79;105;48;2;241;239;239m()[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;210;15;57;48;2;241;239;239mstring[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;76;79;105;48;2;241;239;239m{[m[m[48;2;241;239;239m                      [m
 [48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m12[m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m[38;2;76;79;105;48;2;255;235;238m    [m[38;2;136;57;239;48;2;255;235;238mreturn[m[38;2;76;79;105;48;2;255;235;238m [m[38;2;64;160;43;48;2;255;235;238m"Hello, world!"[m[m[48;2;255;235;238m                      [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden
index 2816e081..9dfd2891 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden
@@ -12,7 +12,4 @@
 [48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m11[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;210;15;57;48;2;241;239;239mfunc[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;30;102;245;48;2;241;239;239mgetContent[m[38;2;76;79;105;48;2;241;239;239m()[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;210;15;57;48;2;241;239;239mstring[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;76;79;105;48;2;241;239;239m{[m[m[48;2;241;239;239m                      [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m12[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;210;15;57;48;2;241;239;239mfunc[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;30;102;245;48;2;241;239;239mgetContent[m[38;2;76;79;105;48;2;241;239;239m()[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;210;15;57;48;2;241;239;239mstring[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;76;79;105;48;2;241;239;239m{[m[m[48;2;241;239;239m                      [m
 [48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m12[m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m[38;2;76;79;105;48;2;255;235;238m    [m[38;2;136;57;239;48;2;255;235;238mreturn[m[38;2;76;79;105;48;2;255;235;238m [m[38;2;64;160;43;48;2;255;235;238m"Hello, world!"[m[m[48;2;255;235;238m                      [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden
index 774d7124..9dfd2891 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden
@@ -12,8 +12,4 @@
 [48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m11[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;210;15;57;48;2;241;239;239mfunc[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;30;102;245;48;2;241;239;239mgetContent[m[38;2;76;79;105;48;2;241;239;239m()[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;210;15;57;48;2;241;239;239mstring[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;76;79;105;48;2;241;239;239m{[m[m[48;2;241;239;239m                      [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m12[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;210;15;57;48;2;241;239;239mfunc[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;30;102;245;48;2;241;239;239mgetContent[m[38;2;76;79;105;48;2;241;239;239m()[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;210;15;57;48;2;241;239;239mstring[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;76;79;105;48;2;241;239;239m{[m[m[48;2;241;239;239m                      [m
 [48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m12[m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m[38;2;76;79;105;48;2;255;235;238m    [m[38;2;136;57;239;48;2;255;235;238mreturn[m[38;2;76;79;105;48;2;255;235;238m [m[38;2;64;160;43;48;2;255;235;238m"Hello, world!"[m[m[48;2;255;235;238m                      [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden
index 9aa08b5f..9dfd2891 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden
@@ -12,9 +12,4 @@
 [48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m11[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;210;15;57;48;2;241;239;239mfunc[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;30;102;245;48;2;241;239;239mgetContent[m[38;2;76;79;105;48;2;241;239;239m()[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;210;15;57;48;2;241;239;239mstring[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;76;79;105;48;2;241;239;239m{[m[m[48;2;241;239;239m                      [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m12[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;210;15;57;48;2;241;239;239mfunc[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;30;102;245;48;2;241;239;239mgetContent[m[38;2;76;79;105;48;2;241;239;239m()[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;210;15;57;48;2;241;239;239mstring[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;76;79;105;48;2;241;239;239m{[m[m[48;2;241;239;239m                      [m
 [48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m12[m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m[38;2;76;79;105;48;2;255;235;238m    [m[38;2;136;57;239;48;2;255;235;238mreturn[m[38;2;76;79;105;48;2;255;235;238m [m[38;2;64;160;43;48;2;255;235;238m"Hello, world!"[m[m[48;2;255;235;238m                      [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden
index d21fa926..60174240 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden
@@ -13,5 +13,4 @@
 [48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m12[m[48;2;255;205;210m [m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m  [m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m[38;2;76;79;105;48;2;255;235;238m    [m[38;2;136;57;239;48;2;255;235;238mreturn[m[38;2;76;79;105;48;2;255;235;238m [m[38;2;64;160;43;48;2;255;235;238m"Hello, world!"[m[m[48;2;255;235;238m                      [m
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden
index 6ce4dd6d..60174240 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden
@@ -13,6 +13,4 @@
 [48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m12[m[48;2;255;205;210m [m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m  [m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m[38;2;76;79;105;48;2;255;235;238m    [m[38;2;136;57;239;48;2;255;235;238mreturn[m[38;2;76;79;105;48;2;255;235;238m [m[38;2;64;160;43;48;2;255;235;238m"Hello, world!"[m[m[48;2;255;235;238m                      [m
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden
index aabfe043..60174240 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden
@@ -13,7 +13,4 @@
 [48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m12[m[48;2;255;205;210m [m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m  [m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m[38;2;76;79;105;48;2;255;235;238m    [m[38;2;136;57;239;48;2;255;235;238mreturn[m[38;2;76;79;105;48;2;255;235;238m [m[38;2;64;160;43;48;2;255;235;238m"Hello, world!"[m[m[48;2;255;235;238m                      [m
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden
index 7d6cb893..60174240 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden
@@ -13,8 +13,4 @@
 [48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m12[m[48;2;255;205;210m [m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m  [m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m[38;2;76;79;105;48;2;255;235;238m    [m[38;2;136;57;239;48;2;255;235;238mreturn[m[38;2;76;79;105;48;2;255;235;238m [m[38;2;64;160;43;48;2;255;235;238m"Hello, world!"[m[m[48;2;255;235;238m                      [m
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden
index 65a88103..623e5cf0 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden
@@ -1,5 +1,4 @@
 [48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m11[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;210;15;57;48;2;241;239;239mfunc[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;30;102;245;48;2;241;239;239mgetContent[m[38;2;76;79;105;48;2;241;239;239m()[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;210;15;57;48;2;241;239;239mstring[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;76;79;105;48;2;241;239;239m{[m[m[48;2;241;239;239m                      [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m12[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;210;15;57;48;2;241;239;239mfunc[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;30;102;245;48;2;241;239;239mgetContent[m[38;2;76;79;105;48;2;241;239;239m()[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;210;15;57;48;2;241;239;239mstring[m[38;2;76;79;105;48;2;241;239;239m [m[38;2;76;79;105;48;2;241;239;239m{[m[m[48;2;241;239;239m                      [m
 [48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m12[m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m[38;2;76;79;105;48;2;255;235;238m    [m[38;2;136;57;239;48;2;255;235;238mreturn[m[38;2;76;79;105;48;2;255;235;238m [m[38;2;64;160;43;48;2;255;235;238m"Hello, world!"[m[m[48;2;255;235;238m                      [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden
index 543ac2c2..9eb555aa 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden
@@ -1,5 +1,3 @@
 [48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m12[m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m[38;2;76;79;105;48;2;255;235;238m    [m[38;2;136;57;239;48;2;255;235;238mreturn[m[38;2;76;79;105;48;2;255;235;238m [m[38;2;64;160;43;48;2;255;235;238m"Hello, world!"[m[m[48;2;255;235;238m                      [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden
index 6d8f9d2e..c3fa138d 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden
@@ -1,5 +1,2 @@
 [48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden
index 8953b980..11b05301 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden
@@ -1,5 +1 @@
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden
index 3ec6c919..e69de29b 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden
@@ -1,5 +0,0 @@
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden
index 3ec6c919..e69de29b 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden
@@ -1,5 +0,0 @@
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m                                                 [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden
index e3ff1510..214db37c 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden
@@ -1,5 +1,4 @@
 [48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m12[m[48;2;255;205;210m [m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;205;210m  [m[48;2;255;205;210m [m[38;2;255;56;139;48;2;255;235;238m- [m[38;2;32;31;38;48;2;255;235;238m[38;2;76;79;105;48;2;255;235;238m    [m[38;2;136;57;239;48;2;255;235;238mreturn[m[38;2;76;79;105;48;2;255;235;238m [m[38;2;64;160;43;48;2;255;235;238m"Hello, world!"[m[m[48;2;255;235;238m                      [m
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden
index 4ba4c150..f646c872 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden
@@ -1,5 +1,3 @@
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m13[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;76;79;105;48;2;232;245;233mcontent[m[38;2;76;79;105;48;2;232;245;233m [m[1;38;2;4;165;229;48;2;232;245;233m:=[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mstrings[m[38;2;76;79;105;48;2;232;245;233m.[m[38;2;30;102;245;48;2;232;245;233mToUpper[m[38;2;76;79;105;48;2;232;245;233m([m[38;2;64;160;43;48;2;232;245;233m"Hello, World!"[m[38;2;76;79;105;48;2;232;245;233m)[m[m[48;2;232;245;233m [m
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden
index dd6b2808..df705472 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden
@@ -1,5 +1,2 @@
 [48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m  [m[48;2;200;230;201m [m[48;2;200;230;201m [m[38;2;10;220;217;48;2;200;230;201m14[m[48;2;200;230;201m [m[38;2;10;220;217;48;2;232;245;233m+ [m[38;2;32;31;38;48;2;232;245;233m[38;2;76;79;105;48;2;232;245;233m    [m[38;2;136;57;239;48;2;232;245;233mreturn[m[38;2;76;79;105;48;2;232;245;233m [m[38;2;76;79;105;48;2;232;245;233mcontent[m[m[48;2;232;245;233m                              [m
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden
index d5989d13..c8029f8b 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden
@@ -1,5 +1 @@
-[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
\ No newline at end of file
+[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m13[m[48;2;223;219;221m [m[48;2;223;219;221m [m[38;2;58;57;67;48;2;223;219;221m15[m[48;2;223;219;221m [m[38;2;32;31;38;48;2;241;239;239m  [38;2;76;79;105;48;2;241;239;239m}[m[m[48;2;241;239;239m                                               [m
\ No newline at end of file
diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden
index a6fe41cb..e69de29b 100644
--- a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden
+++ b/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden
@@ -1,5 +0,0 @@
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
-[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m [m[48;2;223;219;221m  [m[48;2;223;219;221m                                                [m
\ No newline at end of file
diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go
index 13208d39..ce61f2c0 100644
--- a/internal/tui/exp/list/filterable_test.go
+++ b/internal/tui/exp/list/filterable_test.go
@@ -29,7 +29,7 @@ func TestFilterableList(t *testing.T) {
 			cmd()
 		}
 
-		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.selectedItemIdx)
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 }
diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go
index 63cfa599..57ca7883 100644
--- a/internal/tui/exp/list/list_test.go
+++ b/internal/tui/exp/list/list_test.go
@@ -5,9 +5,10 @@ import (
 	"strings"
 	"testing"
 
-	tea "github.com/charmbracelet/bubbletea/v2"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/x/exp/golden"
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
@@ -27,18 +28,18 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.selectedItemIdx)
 		assert.Equal(t, 0, l.offset)
-		require.Equal(t, 5, l.indexMap.Len())
-		require.Equal(t, 5, l.items.Len())
-		require.Equal(t, 5, l.renderedItems.Len())
+		require.Equal(t, 5, len(l.indexMap))
+		require.Equal(t, 5, len(l.items))
+		require.Equal(t, 5, len(l.renderedItems))
 		assert.Equal(t, 5, lipgloss.Height(l.rendered))
 		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 		start, end := l.viewPosition()
 		assert.Equal(t, 0, start)
 		assert.Equal(t, 4, end)
 		for i := range 5 {
-			item, ok := l.renderedItems.Get(items[i].ID())
+			item, ok := l.renderedItems[items[i].ID()]
 			require.True(t, ok)
 			assert.Equal(t, i, item.start)
 			assert.Equal(t, i, item.end)
@@ -57,18 +58,18 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[4].ID(), l.selectedItem)
+		assert.Equal(t, 4, l.selectedItemIdx)
 		assert.Equal(t, 0, l.offset)
-		require.Equal(t, 5, l.indexMap.Len())
-		require.Equal(t, 5, l.items.Len())
-		require.Equal(t, 5, l.renderedItems.Len())
+		require.Equal(t, 5, len(l.indexMap))
+		require.Equal(t, 5, len(l.items))
+		require.Equal(t, 5, len(l.renderedItems))
 		assert.Equal(t, 5, lipgloss.Height(l.rendered))
 		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 		start, end := l.viewPosition()
 		assert.Equal(t, 0, start)
 		assert.Equal(t, 4, end)
 		for i := range 5 {
-			item, ok := l.renderedItems.Get(items[i].ID())
+			item, ok := l.renderedItems[items[i].ID()]
 			require.True(t, ok)
 			assert.Equal(t, i, item.start)
 			assert.Equal(t, i, item.end)
@@ -88,18 +89,18 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.selectedItemIdx)
 		assert.Equal(t, 0, l.offset)
-		require.Equal(t, 30, l.indexMap.Len())
-		require.Equal(t, 30, l.items.Len())
-		require.Equal(t, 30, l.renderedItems.Len())
+		require.Equal(t, 30, len(l.indexMap))
+		require.Equal(t, 30, len(l.items))
+		require.Equal(t, 30, len(l.renderedItems))
 		assert.Equal(t, 30, lipgloss.Height(l.rendered))
 		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 		start, end := l.viewPosition()
 		assert.Equal(t, 0, start)
 		assert.Equal(t, 9, end)
 		for i := range 30 {
-			item, ok := l.renderedItems.Get(items[i].ID())
+			item, ok := l.renderedItems[items[i].ID()]
 			require.True(t, ok)
 			assert.Equal(t, i, item.start)
 			assert.Equal(t, i, item.end)
@@ -118,18 +119,18 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[29].ID(), l.selectedItem)
+		assert.Equal(t, 29, l.selectedItemIdx)
 		assert.Equal(t, 0, l.offset)
-		require.Equal(t, 30, l.indexMap.Len())
-		require.Equal(t, 30, l.items.Len())
-		require.Equal(t, 30, l.renderedItems.Len())
+		require.Equal(t, 30, len(l.indexMap))
+		require.Equal(t, 30, len(l.items))
+		require.Equal(t, 30, len(l.renderedItems))
 		assert.Equal(t, 30, lipgloss.Height(l.rendered))
 		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 		start, end := l.viewPosition()
 		assert.Equal(t, 20, start)
 		assert.Equal(t, 29, end)
 		for i := range 30 {
-			item, ok := l.renderedItems.Get(items[i].ID())
+			item, ok := l.renderedItems[items[i].ID()]
 			require.True(t, ok)
 			assert.Equal(t, i, item.start)
 			assert.Equal(t, i, item.end)
@@ -151,11 +152,11 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.selectedItemIdx)
 		assert.Equal(t, 0, l.offset)
-		require.Equal(t, 30, l.indexMap.Len())
-		require.Equal(t, 30, l.items.Len())
-		require.Equal(t, 30, l.renderedItems.Len())
+		require.Equal(t, 30, len(l.indexMap))
+		require.Equal(t, 30, len(l.items))
+		require.Equal(t, 30, len(l.renderedItems))
 		expectedLines := 0
 		for i := range 30 {
 			expectedLines += (i + 1) * 1
@@ -167,7 +168,7 @@ func TestList(t *testing.T) {
 		assert.Equal(t, 9, end)
 		currentPosition := 0
 		for i := range 30 {
-			rItem, ok := l.renderedItems.Get(items[i].ID())
+			rItem, ok := l.renderedItems[items[i].ID()]
 			require.True(t, ok)
 			assert.Equal(t, currentPosition, rItem.start)
 			assert.Equal(t, currentPosition+i, rItem.end)
@@ -189,11 +190,11 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[29].ID(), l.selectedItem)
+		assert.Equal(t, 29, l.selectedItemIdx)
 		assert.Equal(t, 0, l.offset)
-		require.Equal(t, 30, l.indexMap.Len())
-		require.Equal(t, 30, l.items.Len())
-		require.Equal(t, 30, l.renderedItems.Len())
+		require.Equal(t, 30, len(l.indexMap))
+		require.Equal(t, 30, len(l.items))
+		require.Equal(t, 30, len(l.renderedItems))
 		expectedLines := 0
 		for i := range 30 {
 			expectedLines += (i + 1) * 1
@@ -205,7 +206,7 @@ func TestList(t *testing.T) {
 		assert.Equal(t, expectedLines-1, end)
 		currentPosition := 0
 		for i := range 30 {
-			rItem, ok := l.renderedItems.Get(items[i].ID())
+			rItem, ok := l.renderedItems[items[i].ID()]
 			require.True(t, ok)
 			assert.Equal(t, currentPosition, rItem.start)
 			assert.Equal(t, currentPosition+i, rItem.end)
@@ -228,7 +229,7 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[10].ID(), l.selectedItem)
+		assert.Equal(t, 10, l.selectedItemIdx)
 
 		golden.RequireEqual(t, []byte(l.View()))
 	})
@@ -246,7 +247,7 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[10].ID(), l.selectedItem)
+		assert.Equal(t, 10, l.selectedItemIdx)
 
 		golden.RequireEqual(t, []byte(l.View()))
 	})
@@ -602,7 +603,7 @@ func (s *simpleItem) Init() tea.Cmd {
 	return nil
 }
 
-func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *simpleItem) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	return s, nil
 }
 
@@ -644,7 +645,7 @@ func (s *selectableItem) IsFocused() bool {
 	return s.focused
 }
 
-func execCmd(m tea.Model, cmd tea.Cmd) {
+func execCmd(m util.Model, cmd tea.Cmd) {
 	for cmd != nil {
 		msg := cmd()
 		m, cmd = m.Update(msg)