Repository: charmbracelet/crush

Issue:
Maintain a centralized index of all `.crush` folders
### Description

I'd like to add support for Crush to [Splitrail](https://github.com/Piebald-AI/splitrail), our agentic coding tool usage analyzer, which already supports Claude Code, Gemini CLI, Codex, Qwen Code, OpenCode, Cline, Roo Code, Kilo Code, and GitHub Copilot.  I found that Crush stores great usage details, but unfortunately it stores them in per-project `.crush` folders, not in a centralized location like most other tools, so AFAIK there's no way for a tool like Splitrail to analyze per-project data without doing a brute force search across the whole filesystem.

It's really a sensible and cool design architecture to keep chat data in _with_ the project, maybe even committing it.  I'm not proposing changing this at all; rather I think it would be very helpful if Crush automatically maintained a centralized list of directories it's run in, e.g. `~/.local/share/crush/projects.json` (along with `crush.json` and `providers.json`).

That would make analyzing the usage across projects instant for Splitrail, and it would also enable features in Crush itself like recent projects, maybe even switching projects if you're in the wrong directory.  Interesting ideas.

I'm open to sending a PR but I'd need to use Crush since I don't use Go :smile:

Hints from issue comments:
@mike1858: Opened a PR: #1553.  cc @meowgorithm

Solution patch:
diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json
index eb3061a6..2fbf3ccd 100644
--- a/.github/cla-signatures.json
+++ b/.github/cla-signatures.json
@@ -903,6 +903,62 @@
       "created_at": "2025-12-04T07:17:10Z",
       "repoId": 987670088,
       "pullRequestNo": 1546
+    },
+    {
+      "name": "mike1858",
+      "id": 223734115,
+      "comment_id": 3615055552,
+      "created_at": "2025-12-05T02:26:29Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1553
+    },
+    {
+      "name": "Guxinpei",
+      "id": 20122411,
+      "comment_id": 3617461839,
+      "created_at": "2025-12-05T15:51:04Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1555
+    },
+    {
+      "name": "mikluko",
+      "id": 42915,
+      "comment_id": 3620605876,
+      "created_at": "2025-12-06T15:43:45Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1558
+    },
+    {
+      "name": "Gustave-241021",
+      "id": 185743951,
+      "comment_id": 3620851967,
+      "created_at": "2025-12-06T18:13:11Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1560
+    },
+    {
+      "name": "mengwong",
+      "id": 1480631,
+      "comment_id": 3636765109,
+      "created_at": "2025-12-10T12:04:50Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1592
+    },
+    {
+      "name": "nonsleepr",
+      "id": 2337602,
+      "comment_id": 3644171671,
+      "created_at": "2025-12-11T23:09:21Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1611
+    },
+    {
+      "name": "strawberry-code",
+      "id": 18684103,
+      "comment_id": 3650628850,
+      "created_at": "2025-12-14T09:41:12Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1628
     }
   ]
 }
\ No newline at end of file
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 5a8ab11c..f24ccb5c 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -11,7 +11,7 @@ jobs:
     outputs:
       should_run: ${{ steps.check.outputs.should_run }}
     steps:
-      - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
+      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
         with:
           fetch-depth: 1
       - id: check
diff --git a/.github/workflows/schema-update.yml b/.github/workflows/schema-update.yml
index ca2cf5e8..d2366652 100644
--- a/.github/workflows/schema-update.yml
+++ b/.github/workflows/schema-update.yml
@@ -10,7 +10,7 @@ jobs:
   update-schema:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
+      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
         with:
           token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
       - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
diff --git a/README.md b/README.md
index 32d7ebd5..e25c99a5 100644
--- a/README.md
+++ b/README.md
@@ -275,6 +275,7 @@ using `$(echo $VAR)` syntax.
       "args": ["/path/to/mcp-server.js"],
       "timeout": 120,
       "disabled": false,
+      "disabled_tools": ["some-tool-name"],
       "env": {
         "NODE_ENV": "production"
       }
@@ -284,6 +285,7 @@ using `$(echo $VAR)` syntax.
       "url": "https://api.githubcopilot.com/mcp/",
       "timeout": 120,
       "disabled": false,
+      "disabled_tools": ["create_issue", "create_pull_request"],
       "headers": {
         "Authorization": "Bearer $GH_PAT"
       }
@@ -335,6 +337,26 @@ permissions. Use this with care.
 You can also skip all permission prompts entirely by running Crush with the
 `--yolo` flag. Be very, very careful with this feature.
 
+### Disabling Built-In Tools
+
+If you'd like to prevent Crush from using certain built-in tools entirely, you
+can disable them via the `options.disabled_tools` list. Disabled tools are
+completely hidden from the agent.
+
+```json
+{
+  "$schema": "https://charm.land/crush.json",
+  "options": {
+    "disabled_tools": [
+      "bash",
+      "sourcegraph"
+    ]
+  }
+}
+```
+
+To disable tools from MCP servers, see the [MCP config section](#mcps).
+
 ### Initialization
 
 When you initialize a project, Crush analyzes your codebase and creates
diff --git a/Taskfile.yaml b/Taskfile.yaml
index 8a5d8dda..f9c20cd6 100644
--- a/Taskfile.yaml
+++ b/Taskfile.yaml
@@ -123,3 +123,10 @@ tasks:
     cmds:
       - git tag -d nightly || true
       - git fetch --tags
+
+  deps:
+    desc: Update Fantasy and Catwalk
+    cmds:
+      - go get charm.land/fantasy
+      - go get github.com/charmbracelet/catwalk
+      - go mod tidy
diff --git a/go.mod b/go.mod
index ce29331f..6567a7cd 100644
--- a/go.mod
+++ b/go.mod
@@ -1,12 +1,14 @@
 module github.com/charmbracelet/crush
 
-go 1.25.0
+go 1.25.5
 
 require (
 	charm.land/bubbles/v2 v2.0.0-rc.1
-	charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251202162339-5fa38b798f16
-	charm.land/fantasy v0.3.4-0.20251204141207-c0d2c86a12f1
-	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca
+	charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251212194116-6348bd4b7475
+	charm.land/fantasy v0.5.2
+	charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1
+	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971
+	charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da
 	charm.land/x/vcr v0.1.1
 	github.com/JohannesKaufmann/html-to-markdown v1.6.0
 	github.com/MakeNowJust/heredoc v1.0.0
@@ -16,13 +18,11 @@ 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/catwalk v0.9.4
-	github.com/charmbracelet/colorprofile v0.3.3
+	github.com/charmbracelet/catwalk v0.10.1
+	github.com/charmbracelet/colorprofile v0.4.1
 	github.com/charmbracelet/fang v0.4.4
-	github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930
-	github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0
-	github.com/charmbracelet/ultraviolet v0.0.0-20251202162030-ecc8c1ae4b2b
-	github.com/charmbracelet/x/ansi v0.11.2
+	github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
+	github.com/charmbracelet/x/ansi v0.11.3
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
 	github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
 	github.com/charmbracelet/x/exp/ordered v0.1.0
@@ -37,7 +37,7 @@ require (
 	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.30.2
+	github.com/ncruces/go-sqlite3 v0.30.3
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
 	github.com/nxadm/tail v1.4.11
 	github.com/openai/openai-go/v2 v2.7.1
@@ -48,15 +48,16 @@ require (
 	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
+	github.com/spf13/cobra v1.10.2
 	github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
 	github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
 	github.com/stretchr/testify v1.11.1
 	github.com/tidwall/sjson v1.2.5
 	github.com/zeebo/xxh3 v1.0.2
-	golang.org/x/mod v0.30.0
-	golang.org/x/sync v0.18.0
-	golang.org/x/text v0.31.0
+	golang.org/x/mod v0.31.0
+	golang.org/x/net v0.48.0
+	golang.org/x/sync v0.19.0
+	golang.org/x/text v0.32.0
 	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
@@ -71,7 +72,7 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
 	github.com/RealAlexandreAI/json-repair v0.0.14 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
-	github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect
+	github.com/aws/aws-sdk-go-v2 v1.40.1 // 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
@@ -84,16 +85,17 @@ 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.23.2 // indirect
+	github.com/aws/smithy-go v1.24.0 // 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/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
+	github.com/charmbracelet/x/etag v0.2.0 // indirect
 	github.com/charmbracelet/x/json v0.2.0 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
-	github.com/clipperhouse/displaywidth v0.6.0 // indirect
+	github.com/clipperhouse/displaywidth v0.6.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
@@ -119,7 +121,7 @@ require (
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/kaptinlin/go-i18n v0.2.0 // indirect
 	github.com/kaptinlin/jsonpointer v0.4.6 // indirect
-	github.com/kaptinlin/jsonschema v0.6.1 // indirect
+	github.com/kaptinlin/jsonschema v0.6.2 // indirect
 	github.com/kaptinlin/messageformat-go v0.4.6 // indirect
 	github.com/klauspost/compress v1.18.0 // indirect
 	github.com/klauspost/cpuid/v2 v2.0.9 // indirect
@@ -161,16 +163,15 @@ require (
 	go.opentelemetry.io/otel/trace v1.37.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
-	golang.org/x/crypto v0.45.0 // indirect
+	golang.org/x/crypto v0.46.0 // indirect
 	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
 	golang.org/x/image v0.27.0 // indirect
-	golang.org/x/net v0.47.0 // indirect
 	golang.org/x/oauth2 v0.33.0 // indirect
-	golang.org/x/sys v0.38.0 // indirect
-	golang.org/x/term v0.37.0 // indirect
+	golang.org/x/sys v0.39.0 // indirect
+	golang.org/x/term v0.38.0 // indirect
 	golang.org/x/time v0.12.0 // indirect
 	google.golang.org/api v0.239.0 // indirect
-	google.golang.org/genai v1.36.0 // indirect
+	google.golang.org/genai v1.37.0 // 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
diff --git a/go.sum b/go.sum
index bfbf9d5a..d2db7bcc 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,15 @@
 charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM=
 charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
-charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251202162339-5fa38b798f16 h1:9iVAss7WF8Ax5QBzmZE77aA08JbOMuDpbEhl/Uvc3Eo=
-charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251202162339-5fa38b798f16/go.mod h1:Vsh7/MLC7LQ2Ab8H63SXm6yD/L6o4HDvhdD/IrIRXrU=
-charm.land/fantasy v0.3.4-0.20251204141207-c0d2c86a12f1 h1:lhLvJejrq4f2pMFumqfd9d0wNxZPaNs4Z3UprMhpg/k=
-charm.land/fantasy v0.3.4-0.20251204141207-c0d2c86a12f1/go.mod h1:mGwXNfKUyRm+npj1LLr2mQZorkc8rgVOGWAV/l0fqMk=
-charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca h1:6bVc8OFotCS4sS7HKqxTudP7yn8Y0ODR6df2pdlY/+s=
-charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca/go.mod h1:XSJjv7DaH4zd1Y27kZis295RkEj9OFR9zh2WffQQsKQ=
+charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251212194116-6348bd4b7475 h1:73ohAau8fvq3d54i1bQxVKhBf/vmPYDtV3JZgb+SIQk=
+charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251212194116-6348bd4b7475/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8=
+charm.land/fantasy v0.5.2 h1:4zlNsIcsyF3Jr+1onEuhHqZRzuH1LlxBle/gInS00QY=
+charm.land/fantasy v0.5.2/go.mod h1:SPOsnIlkBKnhw2Wnasv+wZ82EmCMIGesx0je3tgR6+M=
+charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 h1:9q4+yyU7105T3OrOx0csMyKnw89yMSijJ+rVld/Z2ek=
+charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8=
+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiIDbFtWRyDmkKNk1sjojfaom4Zoe0cyH/8c=
+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971/go.mod h1:i61Y3FmdbcBNSKa+pKB3DaE4uVQmBLMs/xlvRyHcXAE=
+charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da h1:vZa/Ow0uLclpfaDY0ubjzE+B0eLQqi2zanmpeALanow=
+charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da/go.mod h1:Tj12StbPc4GwksDF6XwhC9wdXouinIVxRGKKmmmzdSU=
 charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
 charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
 cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
@@ -44,8 +48,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.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
-github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
+github.com/aws/aws-sdk-go-v2 v1.40.1 h1:difXb4maDZkRH0x//Qkwcfpdg1XQVXEAEs2DdXldFFc=
+github.com/aws/aws-sdk-go-v2 v1.40.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
 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=
@@ -70,8 +74,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.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
-github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
+github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
+github.com/aws/smithy-go v1.24.0/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=
@@ -88,20 +92,18 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
 github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
-github.com/charmbracelet/catwalk v0.9.4 h1:et3nP3p1jfJbQE9UiN9vzfojsihgtQp2r9tvn8qEL28=
-github.com/charmbracelet/catwalk v0.9.4/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/catwalk v0.10.1 h1:Yov/bEni8eDU+uP68YuqgHo0UW1sDozVHwHdlWbklJ4=
+github.com/charmbracelet/catwalk v0.10.1/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
+github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
+github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
 github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
 github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
-github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930 h1:+47Z2jVAWPSLGjPRbfZizW3OpcAYsu7EUk2DR+66FyM=
-github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930/go.mod h1:izs11tnkYaT3DTEH2E0V/lCb18VGZ7k9HLYEGuvgXGA=
-github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0 h1:lxHzxsHd4P7o7+5D5OcEItYkQ1xY3ovNg8Dc5ftd3rI=
-github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0/go.mod h1:Q7oMtlboDPnnrYiJDXNwdWmJblOmuOnycPKczlVju6I=
-github.com/charmbracelet/ultraviolet v0.0.0-20251202162030-ecc8c1ae4b2b h1:jY1J0PcfetoB1uJ+w8rd86gUFSpKpJJI35gnfpKF5hg=
-github.com/charmbracelet/ultraviolet v0.0.0-20251202162030-ecc8c1ae4b2b/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
-github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
-github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
+github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 h1:j3PW2hypGoPKBy3ooKzW0TFxaxhyHK3NbkLLn4KeRFc=
+github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560/go.mod h1:VWATWLRwYP06VYCEur7FsNR2B1xAo7Y+xl1PTbd1ePc=
+github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
+github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
+github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04=
+github.com/charmbracelet/x/etag v0.2.0/go.mod h1:C1B7/bsgvzzxpfu0Rabbd+rTHJa5TmC/qgTseCf6DF0=
 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-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
@@ -120,8 +122,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
 github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
 github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
-github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
-github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
+github.com/clipperhouse/displaywidth v0.6.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=
@@ -199,8 +201,8 @@ github.com/kaptinlin/go-i18n v0.2.0 h1:8iwjAERQbCVF78c3HxC4MxUDxDRFvQVQlMDvlsO43
 github.com/kaptinlin/go-i18n v0.2.0/go.mod h1:gRHEMrTHtQLsAFwulPbJG71TwHjXxkagn88O8FI8FuA=
 github.com/kaptinlin/jsonpointer v0.4.6 h1:hAett1YROLwxAOKZS08hsJueXr1w0fTMSvWq2x1IoUA=
 github.com/kaptinlin/jsonpointer v0.4.6/go.mod h1:5pHXLIYd2FgV0rUEsChp6xTOvcC2OFk7kF/cjhHzL4g=
-github.com/kaptinlin/jsonschema v0.6.1 h1:RNUQ11ZCHTtM80YcVwRm033H5OJS+MpO06d9x7Yk25o=
-github.com/kaptinlin/jsonschema v0.6.1/go.mod h1:T8SNWNTRLDS1w+ogMZpGYqIfUXn/8DK9r06mf8XbNLE=
+github.com/kaptinlin/jsonschema v0.6.2 h1:+c9W6I2k/8+yMrwYb9fZCQGAROJTMGM9ZSqLducrBlg=
+github.com/kaptinlin/jsonschema v0.6.2/go.mod h1:N7rMNv64BLaLPa7IA6ul5FqVlwzeIn6dSm5cqVpNCmM=
 github.com/kaptinlin/messageformat-go v0.4.6 h1:57DUC9en40mGZR7MvqOS+5EYogAl465fjo+loAA1KPg=
 github.com/kaptinlin/messageformat-go v0.4.6/go.mod h1:r0PH7FsxJX8jS/n6LAYZon5w3X+yfCLUrquqYd2H7ks=
 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -246,8 +248,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
-github.com/ncruces/go-sqlite3 v0.30.2 h1:1GVbHAkKAOwjJd3JYl8ldrYROudfZUOah7oXPD7VZbQ=
-github.com/ncruces/go-sqlite3 v0.30.2/go.mod h1:AxKu9sRxkludimFocbktlY6LiYSkxiI5gTA8r+os/Nw=
+github.com/ncruces/go-sqlite3 v0.30.3 h1:X/CgWW9GzmIAkEPrifhKqf0cC15DuOVxAJaHFTTAURQ=
+github.com/ncruces/go-sqlite3 v0.30.3/go.mod h1:AxKu9sRxkludimFocbktlY6LiYSkxiI5gTA8r+os/Nw=
 github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
 github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@@ -294,8 +296,8 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah
 github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
 github.com/sourcegraph/jsonrpc2 v0.2.1 h1:2GtljixMQYUYCmIg7W9aF2dFmniq/mOr2T9tFRh6zSQ=
 github.com/sourcegraph/jsonrpc2 v0.2.1/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=
-github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
-github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
@@ -358,6 +360,7 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
 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/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
 go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -367,8 +370,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
 golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
 golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
-golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
+golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
 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.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
@@ -378,8 +381,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
-golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
+golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
+golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -391,8 +394,8 @@ 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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
-golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
 golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
 golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -402,8 +405,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
-golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -420,8 +423,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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.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=
@@ -433,8 +436,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
-golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
+golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
+golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -444,8 +447,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
 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=
@@ -454,13 +457,13 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
-golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
 google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
-google.golang.org/genai v1.36.0 h1:sJCIjqTAmwrtAIaemtTiKkg2TO1RxnYEusTmEQ3nGxM=
-google.golang.org/genai v1.36.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
+google.golang.org/genai v1.37.0 h1:dgp71k1wQ+/+APdZrN3LFgAGnVnr5IdTF1Oj0Dg+BQc=
+google.golang.org/genai v1.37.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
 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=
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 9b7266c7..5f48ee1c 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -11,6 +11,7 @@ import (
 	"cmp"
 	"context"
 	_ "embed"
+	"encoding/base64"
 	"errors"
 	"fmt"
 	"log/slog"
@@ -64,6 +65,7 @@ type SessionAgent interface {
 	IsSessionBusy(sessionID string) bool
 	IsBusy() bool
 	QueuedPrompts(sessionID string) int
+	QueuedPromptsList(sessionID string) []string
 	ClearQueue(sessionID string)
 	Summarize(context.Context, string, fantasy.ProviderOptions) error
 	Model() Model
@@ -80,6 +82,7 @@ type sessionAgent struct {
 	smallModel           Model
 	systemPromptPrefix   string
 	systemPrompt         string
+	isSubAgent           bool
 	tools                []fantasy.AgentTool
 	sessions             session.Service
 	messages             message.Service
@@ -95,6 +98,7 @@ type SessionAgentOptions struct {
 	SmallModel           Model
 	SystemPromptPrefix   string
 	SystemPrompt         string
+	IsSubAgent           bool
 	DisableAutoSummarize bool
 	IsYolo               bool
 	Sessions             session.Service
@@ -110,6 +114,7 @@ func NewSessionAgent(
 		smallModel:           opts.SmallModel,
 		systemPromptPrefix:   opts.SystemPromptPrefix,
 		systemPrompt:         opts.SystemPrompt,
+		isSubAgent:           opts.IsSubAgent,
 		sessions:             opts.Sessions,
 		messages:             opts.Messages,
 		disableAutoSummarize: opts.DisableAutoSummarize,
@@ -204,10 +209,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 		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
 			}
@@ -222,6 +225,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 				prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
 			}
 
+			prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages)
+
 			lastSystemRoleInx := 0
 			systemMessageUpdated := false
 			for i, msg := range prepared.Messages {
@@ -253,6 +258,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 				return callContext, prepared, err
 			}
 			callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
+			callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, a.largeModel.CatwalkCfg.SupportsImages)
+			callContext = context.WithValue(callContext, tools.ModelNameContextKey, a.largeModel.CatwalkCfg.Name)
 			currentAssistant = &assistantMsg
 			return callContext, prepared, err
 		},
@@ -320,40 +327,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			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,
-			}
+			toolResult := a.convertToToolResult(result)
 			_, createMsgErr := a.messages.Create(genCtx, currentAssistant.SessionID, message.CreateMessageParams{
 				Role: message.Tool,
 				Parts: []message.ContentPart{
 					toolResult,
 				},
 			})
-			if createMsgErr != nil {
-				return createMsgErr
-			}
-			return nil
+			return createMsgErr
 		},
 		OnStepFinish: func(stepResult fantasy.StepResult) error {
 			finishReason := message.FinishReasonUnknown
@@ -366,9 +347,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 				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)
+			updatedSession, getSessionErr := a.sessions.Get(genCtx, call.SessionID)
+			if getSessionErr != nil {
+				sessionLock.Unlock()
+				return getSessionErr
+			}
+			a.updateSessionUsage(a.largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
+			_, sessionErr := a.sessions.Save(genCtx, updatedSession)
 			sessionLock.Unlock()
 			if sessionErr != nil {
 				return sessionErr
@@ -554,8 +540,18 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
 		return err
 	}
 
+	summaryPromptText := "Provide a detailed summary of our conversation above."
+	if len(currentSession.Todos) > 0 {
+		summaryPromptText += "\n\n## Current Todo List\n\n"
+		for _, t := range currentSession.Todos {
+			summaryPromptText += fmt.Sprintf("- [%s] %s\n", t.Status, t.Content)
+		}
+		summaryPromptText += "\nInclude these tasks and their statuses in your summary. "
+		summaryPromptText += "Instruct the resuming assistant to use the `todos` tool to continue tracking progress on these tasks."
+	}
+
 	resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
-		Prompt:          "Provide a detailed summary of our conversation above.",
+		Prompt:          summaryPromptText,
 		Messages:        aiMsgs,
 		ProviderOptions: opts,
 		PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
@@ -656,6 +652,15 @@ func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentC
 
 func (a *sessionAgent) preparePrompt(msgs []message.Message, attachments ...message.Attachment) ([]fantasy.Message, []fantasy.FilePart) {
 	var history []fantasy.Message
+	if !a.isSubAgent {
+		history = append(history, fantasy.NewUserMessage(
+			fmt.Sprintf("<system_reminder>%s</system_reminder>",
+				`This is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware.
+If you are working on tasks that would benefit from a todo list please use the "todos" tool to create one.
+If not, please feel free to ignore. Again do not mention this message to the user.`,
+			),
+		))
+	}
 	for _, m := range msgs {
 		if len(m.Parts) == 0 {
 			continue
@@ -874,6 +879,18 @@ func (a *sessionAgent) QueuedPrompts(sessionID string) int {
 	return len(l)
 }
 
+func (a *sessionAgent) QueuedPromptsList(sessionID string) []string {
+	l, ok := a.messageQueue.Get(sessionID)
+	if !ok {
+		return nil
+	}
+	prompts := make([]string, len(l))
+	for i, call := range l {
+		prompts[i] = call.Prompt
+	}
+	return prompts
+}
+
 func (a *sessionAgent) SetModels(large Model, small Model) {
 	a.largeModel = large
 	a.smallModel = small
@@ -899,3 +916,125 @@ func (a *sessionAgent) isClaudeCode() bool {
 	pc, ok := cfg.Providers.Get(a.largeModel.ModelCfg.Provider)
 	return ok && pc.ID == string(catwalk.InferenceProviderAnthropic) && pc.OAuthToken != nil
 }
+
+// convertToToolResult converts a fantasy tool result to a message tool result.
+func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) message.ToolResult {
+	baseResult := message.ToolResult{
+		ToolCallID: result.ToolCallID,
+		Name:       result.ToolName,
+		Metadata:   result.ClientMetadata,
+	}
+
+	switch result.Result.GetType() {
+	case fantasy.ToolResultContentTypeText:
+		if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result.Result); ok {
+			baseResult.Content = r.Text
+		}
+	case fantasy.ToolResultContentTypeError:
+		if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result.Result); ok {
+			baseResult.Content = r.Error.Error()
+			baseResult.IsError = true
+		}
+	case fantasy.ToolResultContentTypeMedia:
+		if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](result.Result); ok {
+			content := r.Text
+			if content == "" {
+				content = fmt.Sprintf("Loaded %s content", r.MediaType)
+			}
+			baseResult.Content = content
+			baseResult.Data = r.Data
+			baseResult.MIMEType = r.MediaType
+		}
+	}
+
+	return baseResult
+}
+
+// workaroundProviderMediaLimitations converts media content in tool results to
+// user messages for providers that don't natively support images in tool results.
+//
+// Problem: OpenAI, Google, OpenRouter, and other OpenAI-compatible providers
+// don't support sending images/media in tool result messages - they only accept
+// text in tool results. However, they DO support images in user messages.
+//
+// If we send media in tool results to these providers, the API returns an error.
+//
+// Solution: For these providers, we:
+//  1. Replace the media in the tool result with a text placeholder
+//  2. Inject a user message immediately after with the image as a file attachment
+//  3. This maintains the tool execution flow while working around API limitations
+//
+// Anthropic and Bedrock support images natively in tool results, so we skip
+// this workaround for them.
+//
+// Example transformation:
+//
+//	BEFORE: [tool result: image data]
+//	AFTER:  [tool result: "Image loaded - see attached"], [user: image attachment]
+func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message) []fantasy.Message {
+	providerSupportsMedia := a.largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
+		a.largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
+
+	if providerSupportsMedia {
+		return messages
+	}
+
+	convertedMessages := make([]fantasy.Message, 0, len(messages))
+
+	for _, msg := range messages {
+		if msg.Role != fantasy.MessageRoleTool {
+			convertedMessages = append(convertedMessages, msg)
+			continue
+		}
+
+		textParts := make([]fantasy.MessagePart, 0, len(msg.Content))
+		var mediaFiles []fantasy.FilePart
+
+		for _, part := range msg.Content {
+			toolResult, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
+			if !ok {
+				textParts = append(textParts, part)
+				continue
+			}
+
+			if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResult.Output); ok {
+				decoded, err := base64.StdEncoding.DecodeString(media.Data)
+				if err != nil {
+					slog.Warn("failed to decode media data", "error", err)
+					textParts = append(textParts, part)
+					continue
+				}
+
+				mediaFiles = append(mediaFiles, fantasy.FilePart{
+					Data:      decoded,
+					MediaType: media.MediaType,
+					Filename:  fmt.Sprintf("tool-result-%s", toolResult.ToolCallID),
+				})
+
+				textParts = append(textParts, fantasy.ToolResultPart{
+					ToolCallID: toolResult.ToolCallID,
+					Output: fantasy.ToolResultOutputContentText{
+						Text: "[Image/media content loaded - see attached file]",
+					},
+					ProviderOptions: toolResult.ProviderOptions,
+				})
+			} else {
+				textParts = append(textParts, part)
+			}
+		}
+
+		convertedMessages = append(convertedMessages, fantasy.Message{
+			Role:    fantasy.MessageRoleTool,
+			Content: textParts,
+		})
+
+		if len(mediaFiles) > 0 {
+			convertedMessages = append(convertedMessages, fantasy.NewUserMessage(
+				"Here is the media content from the tool result:",
+				mediaFiles...,
+			))
+		}
+	}
+
+	return convertedMessages
+}
diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go
index ef106809..29566b1c 100644
--- a/internal/agent/agent_tool.go
+++ b/internal/agent/agent_tool.go
@@ -34,7 +34,7 @@ func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error)
 		return nil, err
 	}
 
-	agent, err := c.buildAgent(ctx, prompt, agentCfg)
+	agent, err := c.buildAgent(ctx, prompt, agentCfg, true)
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go
index 75fc9d0d..bebdbbaf 100644
--- a/internal/agent/agentic_fetch_tool.go
+++ b/internal/agent/agentic_fetch_tool.go
@@ -27,10 +27,6 @@ type agenticFetchValidationResult struct {
 
 // 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")
 	}
@@ -75,6 +71,14 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 				return fantasy.NewTextErrorResponse(err.Error()), nil
 			}
 
+			// Determine description based on mode.
+			var description string
+			if params.URL != "" {
+				description = fmt.Sprintf("Fetch and analyze content from URL: %s", params.URL)
+			} else {
+				description = "Search the web and analyze results"
+			}
+
 			p := c.permissions.Request(
 				permission.CreatePermissionRequest{
 					SessionID:   validationResult.SessionID,
@@ -82,7 +86,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 					ToolCallID:  call.ID,
 					ToolName:    tools.AgenticFetchToolName,
 					Action:      "fetch",
-					Description: fmt.Sprintf("Fetch and analyze content from URL: %s", params.URL),
+					Description: description,
 					Params:      tools.AgenticFetchPermissionsParams(params),
 				},
 			)
@@ -91,36 +95,43 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 				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 params.URL != "" {
+				// URL mode: fetch the URL content first.
+				content, err := tools.FetchURLAndConvert(ctx, client, params.URL)
 				if err != nil {
-					return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary file: %s", err)), nil
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to fetch URL: %s", err)), nil
 				}
-				tempFilePath := tempFile.Name()
 
-				if _, err := tempFile.WriteString(content); err != nil {
+				hasLargeContent := len(content) > tools.LargeContentThreshold
+
+				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()
-					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)
+					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)
+				}
 			} else {
-				fullPrompt = fmt.Sprintf("%s\n\nWeb page URL: %s\n\n<webpage_content>\n%s\n</webpage_content>", params.Prompt, params.URL, content)
+				// Search mode: let the sub-agent search and fetch as needed.
+				fullPrompt = fmt.Sprintf("%s\n\nUse the web_search tool to find relevant information. Break down the question into smaller, focused searches if needed. After searching, use web_fetch to get detailed content from the most relevant results.", params.Prompt)
 			}
 
 			promptOpts := []prompt.Option{
@@ -148,10 +159,13 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 			}
 
 			webFetchTool := tools.NewWebFetchTool(tmpDir, client)
+			webSearchTool := tools.NewWebSearchTool(client)
 			fetchTools := []fantasy.AgentTool{
 				webFetchTool,
+				webSearchTool,
 				tools.NewGlobTool(tmpDir),
 				tools.NewGrepTool(tmpDir),
+				tools.NewSourcegraphTool(client),
 				tools.NewViewTool(c.lspClients, c.permissions, tmpDir),
 			}
 
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index 91463fe4..14f3cf65 100644
--- a/internal/agent/coordinator.go
+++ b/internal/agent/coordinator.go
@@ -10,6 +10,7 @@ import (
 	"io"
 	"log/slog"
 	"maps"
+	"net/http"
 	"os"
 	"slices"
 	"strings"
@@ -48,6 +49,7 @@ type Coordinator interface {
 	IsSessionBusy(sessionID string) bool
 	IsBusy() bool
 	QueuedPrompts(sessionID string) int
+	QueuedPromptsList(sessionID string) []string
 	ClearQueue(sessionID string)
 	Summarize(context.Context, string) error
 	Model() Model
@@ -98,7 +100,7 @@ func NewCoordinator(
 		return nil, err
 	}
 
-	agent, err := c.buildAgent(ctx, prompt, agentCfg)
+	agent, err := c.buildAgent(ctx, prompt, agentCfg, false)
 	if err != nil {
 		return nil, err
 	}
@@ -130,32 +132,42 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 
 	mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
 
-	if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
-		slog.Info("Detected expired OAuth token, attempting refresh", "provider", providerCfg.ID)
-		if refreshErr := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); refreshErr != nil {
-			slog.Error("Failed to refresh OAuth token", "provider", providerCfg.ID, "error", refreshErr)
-			return nil, refreshErr
-		}
-
-		// Rebuild models with refreshed token
-		if updateErr := c.UpdateModels(ctx); updateErr != nil {
-			slog.Error("Failed to update models after token refresh", "error", updateErr)
-			return nil, updateErr
+	run := func() (*fantasy.AgentResult, error) {
+		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,
+		})
+	}
+	result, originalErr := run()
+
+	if c.isUnauthorized(originalErr) {
+		switch {
+		case providerCfg.OAuthToken != nil:
+			slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
+			if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
+				return nil, originalErr
+			}
+			slog.Info("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
+			return run()
+		case strings.Contains(providerCfg.APIKeyTemplate, "$"):
+			slog.Info("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
+			if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
+				return nil, originalErr
+			}
+			slog.Info("Retrying request with refreshed API key", "provider", providerCfg.ID)
+			return run()
 		}
 	}
-	result, err := 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,
-	})
-	return result, err
+
+	return result, originalErr
 }
 
 func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
@@ -288,7 +300,7 @@ func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderO
 	return modelOptions, temp, topP, topK, freqPenalty, presPenalty
 }
 
-func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent) (SessionAgent, error) {
+func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
 	large, small, err := c.buildAgentModels(ctx)
 	if err != nil {
 		return nil, err
@@ -305,6 +317,7 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
 		small,
 		largeProviderCfg.SystemPromptPrefix,
 		systemPrompt,
+		isSubAgent,
 		c.cfg.Options.DisableAutoSummarize,
 		c.permissions.SkipRequests(),
 		c.sessions,
@@ -361,6 +374,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		tools.NewGrepTool(c.cfg.WorkingDir()),
 		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
 		tools.NewSourcegraphTool(nil),
+		tools.NewTodosTool(c.sessions),
 		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
 		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
 	)
@@ -377,6 +391,12 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 	}
 
 	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
+		// Check MCP-specific disabled tools.
+		if mcpCfg, ok := c.cfg.MCP[tool.MCP()]; ok {
+			if slices.Contains(mcpCfg.DisabledTools, tool.MCPToolName()) {
+				continue
+			}
+		}
 		if agent.AllowedMCP == nil {
 			// No MCP restrictions
 			filteredTools = append(filteredTools, tool)
@@ -766,6 +786,10 @@ func (c *coordinator) QueuedPrompts(sessionID string) int {
 	return c.currentAgent.QueuedPrompts(sessionID)
 }
 
+func (c *coordinator) QueuedPromptsList(sessionID string) []string {
+	return c.currentAgent.QueuedPromptsList(sessionID)
+}
+
 func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
 	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
 	if !ok {
@@ -773,3 +797,35 @@ func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
 	}
 	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
 }
+
+func (c *coordinator) isUnauthorized(err error) bool {
+	var providerErr *fantasy.ProviderError
+	return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
+}
+
+func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
+	if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
+		slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
+		return err
+	}
+	if err := c.UpdateModels(ctx); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
+	newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
+	if err != nil {
+		slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
+		return err
+	}
+
+	providerCfg.APIKey = newAPIKey
+	c.cfg.Providers.Set(providerCfg.ID, providerCfg)
+
+	if err := c.UpdateModels(ctx); err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/internal/agent/errors.go b/internal/agent/errors.go
index 1b4f0dfc..c6cce5ed 100644
--- a/internal/agent/errors.go
+++ b/internal/agent/errors.go
@@ -1,9 +1,6 @@
 package agent
 
-import (
-	"context"
-	"errors"
-)
+import "errors"
 
 var (
 	ErrRequestCancelled = errors.New("request canceled by user")
@@ -11,7 +8,3 @@ var (
 	ErrEmptyPrompt      = errors.New("prompt is empty")
 	ErrSessionMissing   = errors.New("session id is missing")
 )
-
-func isCancelledErr(err error) bool {
-	return errors.Is(err, context.Canceled) || errors.Is(err, ErrRequestCancelled)
-}
diff --git a/internal/agent/templates/agentic_fetch.md b/internal/agent/templates/agentic_fetch.md
index 06775e34..5cf43b1f 100644
--- a/internal/agent/templates/agentic_fetch.md
+++ b/internal/agent/templates/agentic_fetch.md
@@ -1,12 +1,12 @@
-Fetches content from a specified URL and processes it using an AI model to extract information or answer questions.
+Fetches content from a URL or searches the web, then 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?")
+- Search the web for information (omit the url parameter)
+- Extract specific information from a webpage (provide a url)
+- Answer questions about web content
 - Summarize or analyze web pages
-- Find specific data within large pages
-- Interpret or process web content with AI
+- Research topics by searching and following links
 
 DO NOT use this tool when:
 - You just need raw content without analysis (use fetch instead - faster and cheaper)
@@ -15,37 +15,50 @@ DO NOT use this tool when:
 </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
+- Provide a prompt describing what information you want to find or extract (required)
+- Optionally provide a URL to fetch and analyze specific content
+- If no URL is provided, the agent will search the web to find relevant information
+- The tool spawns a sub-agent with web_search, web_fetch, and analysis tools
+- Returns the agent's response about the content
 </usage>
 
-<usage_notes>
+<parameters>
+- prompt: What information you want to find or extract (required)
+- url: The URL to fetch content from (optional - if not provided, agent will search the web)
+</parameters>
 
+<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>
+- When using URL mode: The URL must be a fully-formed valid URL. HTTP URLs will be automatically upgraded to HTTPS.
+- When searching: Just provide the prompt describing what you want to find - the agent will search and fetch relevant pages.
+- The sub-agent can perform multiple searches and fetch multiple pages to gather comprehensive information.
+- This tool is read-only and does not modify any files.
+- Results will be summarized if the content is very large.
+- This tool uses AI processing and costs more tokens than the simple fetch tool.
+</usage_notes>
 
 <limitations>
-- Max response size: 5MB
+- Max response size: 5MB per page
 - Only supports HTTP and HTTPS protocols
 - Cannot handle authentication or cookies
 - Some websites may block automated requests
 - Uses additional tokens for AI processing
+- Search results depend on DuckDuckGo availability
 </limitations>
 
 <tips>
-- Be specific in your prompt about what information you want to extract
+- Be specific in your prompt about what information you want
+- For research tasks, omit the URL and let the agent search and follow relevant links
 - For complex pages, ask the agent to focus on specific sections
-- The agent has access to grep and view tools when analyzing large pages
+- The agent has access to web_search, web_fetch, grep, and view tools
 - If you just need raw content, use the fetch tool instead to save tokens
 </tips>
+
+<examples>
+Search for information:
+- prompt: "What are the main new features in the latest Python release?"
+
+Fetch and analyze a URL:
+- url: "https://docs.python.org/3/whatsnew/3.12.html"
+- prompt: "Summarize the key changes in Python 3.12"
+</examples>
diff --git a/internal/agent/templates/agentic_fetch_prompt.md.tpl b/internal/agent/templates/agentic_fetch_prompt.md.tpl
index 18e27a9b..e1c3e520 100644
--- a/internal/agent/templates/agentic_fetch_prompt.md.tpl
+++ b/internal/agent/templates/agentic_fetch_prompt.md.tpl
@@ -1,18 +1,38 @@
-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.
+You are a web content analysis agent for Crush. Your task is to analyze web content, search results, or web pages to extract the information requested by the user.
 
 <rules>
-1. You should be concise and direct in your responses
+1. 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
+4. When relevant, quote specific sections from the content 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
+7. **IMPORTANT**: If you need information from a linked page or search result, use the web_fetch tool to get that content
+8. **IMPORTANT**: If you need to search for more information, use the web_search tool
+9. After fetching a link, analyze the content yourself to extract what's needed
+10. Don't hesitate to follow multiple links or perform multiple searches if necessary to get complete information
+11. **CRITICAL**: At the end of your response, include a "Sources" section listing ALL URLs that were useful in answering the question
 </rules>
 
+<search_strategy>
+When searching for information:
+
+1. **Break down complex questions** - If the user's question has multiple parts, search for each part separately
+2. **Use specific, targeted queries** - Prefer multiple small searches over one broad search
+   - Bad: "Python 3.12 new features performance improvements async changes"
+   - Good: First "Python 3.12 new features", then "Python 3.12 performance improvements", then "Python 3.12 async changes"
+3. **Iterate and refine** - If initial results aren't helpful, try different search terms or more specific queries
+4. **Search for different aspects** - For comprehensive answers, search for different angles of the topic
+5. **Follow up on promising results** - When you find a good source, fetch it and look for links to related information
+
+Example workflow for "What are the pros and cons of using Rust vs Go for web services?":
+- Search 1: "Rust web services advantages"
+- Search 2: "Go web services advantages"
+- Search 3: "Rust vs Go performance comparison"
+- Search 4: "Rust vs Go developer experience"
+- Then fetch the most relevant results from each search
+</search_strategy>
+
 <response_format>
 Your response should be structured as follows:
 
@@ -24,7 +44,7 @@ Your response should be structured as follows:
 - [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.
+Only include URLs that actually contributed information to your answer. Include the main URL or search results that were helpful. Add any additional URLs you fetched that provided relevant information.
 </response_format>
 
 <env>
@@ -33,9 +53,20 @@ Platform: {{.Platform}}
 Today's date: {{.Date}}
 </env>
 
+<web_search_tool>
+You have access to a web_search tool that allows you to search the web:
+- Provide a search query and optionally max_results (default: 10)
+- The tool returns search results with titles, URLs, and snippets
+- After getting search results, use web_fetch to get full content from relevant URLs
+- **Prefer multiple focused searches over single broad searches**
+- Keep queries short and specific (3-6 words is often ideal)
+- If results aren't relevant, try rephrasing with different keywords
+- Don't be afraid to do 3-5+ searches to thoroughly answer a complex question
+</web_search_tool>
+
 <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
+You have access to a web_fetch tool that allows you to fetch web pages:
+- Use it when you need to follow links from search results or 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
diff --git a/internal/agent/templates/summary.md b/internal/agent/templates/summary.md
index 937c59d7..5240c22d 100644
--- a/internal/agent/templates/summary.md
+++ b/internal/agent/templates/summary.md
@@ -43,6 +43,6 @@ Be specific. Don't write "implement authentication" - write:
 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.
+**Tone**: Write as if briefing a teammate taking over mid-task. Include everything they'd need to continue without asking questions. No emojis ever.
 
 **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/tools/diagnostics.go b/internal/agent/tools/diagnostics.go
index f44c25b3..de1fc9a1 100644
--- a/internal/agent/tools/diagnostics.go
+++ b/internal/agent/tools/diagnostics.go
@@ -94,7 +94,7 @@ func getDiagnostics(filePath string, lsps *csync.Map[string, *lsp.Client]) strin
 	}
 
 	out := output.String()
-	slog.Info("Diagnostics", "output", out)
+	slog.Debug("Diagnostics", "output", out)
 	return out
 }
 
diff --git a/internal/agent/tools/download.go b/internal/agent/tools/download.go
index 23bebcd6..353b312a 100644
--- a/internal/agent/tools/download.go
+++ b/internal/agent/tools/download.go
@@ -114,12 +114,6 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
 				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)
@@ -132,20 +126,14 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
 			}
 			defer outFile.Close()
 
-			// Copy data with size limit
-			limitedReader := io.LimitReader(resp.Body, maxSize)
-			bytesWritten, err := io.Copy(outFile, limitedReader)
+			// Copy data without an explicit size limit.
+			// The overall download is still constrained by the HTTP client's timeout
+			// and any upstream server limits.
+			bytesWritten, err := io.Copy(outFile, resp.Body)
 			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 != "" {
diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go
index 0ade26b7..ccc115be 100644
--- a/internal/agent/tools/edit.go
+++ b/internal/agent/tools/edit.go
@@ -71,19 +71,12 @@ func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
 
 			if params.OldString == "" {
 				response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
-				if err != nil {
-					return response, err
-				}
-			}
-
-			if params.NewString == "" {
+			} else if params.NewString == "" {
 				response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
-				if err != nil {
-					return response, err
-				}
+			} else {
+				response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
 			}
 
-			response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
 			if err != nil {
 				return response, err
 			}
diff --git a/internal/agent/tools/fetch_helpers.go b/internal/agent/tools/fetch_helpers.go
index 35bf0e0d..34eb3b2f 100644
--- a/internal/agent/tools/fetch_helpers.go
+++ b/internal/agent/tools/fetch_helpers.go
@@ -8,12 +8,17 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"regexp"
 	"strings"
 	"unicode/utf8"
 
 	md "github.com/JohannesKaufmann/html-to-markdown"
+	"golang.org/x/net/html"
 )
 
+// BrowserUserAgent is a realistic browser User-Agent for better compatibility.
+const BrowserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+
 // 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)
@@ -21,7 +26,10 @@ func FetchURLAndConvert(ctx context.Context, client *http.Client, url string) (s
 		return "", fmt.Errorf("failed to create request: %w", err)
 	}
 
-	req.Header.Set("User-Agent", "crush/1.0")
+	// Use realistic browser headers for better compatibility.
+	req.Header.Set("User-Agent", BrowserUserAgent)
+	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
+	req.Header.Set("Accept-Language", "en-US,en;q=0.5")
 
 	resp, err := client.Do(req)
 	if err != nil {
@@ -49,11 +57,13 @@ func FetchURLAndConvert(ctx context.Context, client *http.Client, url string) (s
 
 	// Convert HTML to markdown for better AI processing.
 	if strings.Contains(contentType, "text/html") {
-		markdown, err := ConvertHTMLToMarkdown(content)
+		// Remove noisy elements before conversion.
+		cleanedHTML := removeNoisyElements(content)
+		markdown, err := ConvertHTMLToMarkdown(cleanedHTML)
 		if err != nil {
 			return "", fmt.Errorf("failed to convert HTML to markdown: %w", err)
 		}
-		content = markdown
+		content = cleanupMarkdown(markdown)
 	} else if strings.Contains(contentType, "application/json") || strings.Contains(contentType, "text/json") {
 		// Format JSON for better readability.
 		formatted, err := FormatJSON(content)
@@ -66,11 +76,79 @@ func FetchURLAndConvert(ctx context.Context, client *http.Client, url string) (s
 	return content, nil
 }
 
+// removeNoisyElements removes script, style, nav, header, footer, and other
+// noisy elements from HTML to improve content extraction.
+func removeNoisyElements(htmlContent string) string {
+	doc, err := html.Parse(strings.NewReader(htmlContent))
+	if err != nil {
+		// If parsing fails, return original content.
+		return htmlContent
+	}
+
+	// Elements to remove entirely.
+	noisyTags := map[string]bool{
+		"script":   true,
+		"style":    true,
+		"nav":      true,
+		"header":   true,
+		"footer":   true,
+		"aside":    true,
+		"noscript": true,
+		"iframe":   true,
+		"svg":      true,
+	}
+
+	var removeNodes func(*html.Node)
+	removeNodes = func(n *html.Node) {
+		var toRemove []*html.Node
+
+		for c := n.FirstChild; c != nil; c = c.NextSibling {
+			if c.Type == html.ElementNode && noisyTags[c.Data] {
+				toRemove = append(toRemove, c)
+			} else {
+				removeNodes(c)
+			}
+		}
+
+		for _, node := range toRemove {
+			n.RemoveChild(node)
+		}
+	}
+
+	removeNodes(doc)
+
+	var buf bytes.Buffer
+	if err := html.Render(&buf, doc); err != nil {
+		return htmlContent
+	}
+
+	return buf.String()
+}
+
+// cleanupMarkdown removes excessive whitespace and blank lines from markdown.
+func cleanupMarkdown(content string) string {
+	// Collapse multiple blank lines into at most two.
+	multipleNewlines := regexp.MustCompile(`\n{3,}`)
+	content = multipleNewlines.ReplaceAllString(content, "\n\n")
+
+	// Remove trailing whitespace from each line.
+	lines := strings.Split(content, "\n")
+	for i, line := range lines {
+		lines[i] = strings.TrimRight(line, " \t")
+	}
+	content = strings.Join(lines, "\n")
+
+	// Trim leading/trailing whitespace.
+	content = strings.TrimSpace(content)
+
+	return content
+}
+
 // ConvertHTMLToMarkdown converts HTML content to markdown format.
-func ConvertHTMLToMarkdown(html string) (string, error) {
+func ConvertHTMLToMarkdown(htmlContent string) (string, error) {
 	converter := md.NewConverter("", true, nil)
 
-	markdown, err := converter.ConvertString(html)
+	markdown, err := converter.ConvertString(htmlContent)
 	if err != nil {
 		return "", err
 	}
@@ -80,7 +158,7 @@ func ConvertHTMLToMarkdown(html string) (string, error) {
 
 // FormatJSON formats JSON content with proper indentation.
 func FormatJSON(content string) (string, error) {
-	var data interface{}
+	var data any
 	if err := json.Unmarshal([]byte(content), &data); err != nil {
 		return "", err
 	}
diff --git a/internal/agent/tools/fetch_types.go b/internal/agent/tools/fetch_types.go
index 54754c28..98bcb3b6 100644
--- a/internal/agent/tools/fetch_types.go
+++ b/internal/agent/tools/fetch_types.go
@@ -6,18 +6,21 @@ const AgenticFetchToolName = "agentic_fetch"
 // WebFetchToolName is the name of the web_fetch tool.
 const WebFetchToolName = "web_fetch"
 
+// WebSearchToolName is the name of the web_search tool for sub-agents.
+const WebSearchToolName = "web_search"
+
 // 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"`
+	URL    string `json:"url,omitempty" description:"The URL to fetch content from (optional - if not provided, the agent will search the web)"`
+	Prompt string `json:"prompt" description:"The prompt describing what information to find or extract"`
 }
 
 // AgenticFetchPermissionsParams defines the permission parameters for the agentic fetch tool.
 type AgenticFetchPermissionsParams struct {
-	URL    string `json:"url"`
+	URL    string `json:"url,omitempty"`
 	Prompt string `json:"prompt"`
 }
 
@@ -26,6 +29,12 @@ type WebFetchParams struct {
 	URL string `json:"url" description:"The URL to fetch content from"`
 }
 
+// WebSearchParams defines the parameters for the web_search tool.
+type WebSearchParams struct {
+	Query      string `json:"query" description:"The search query to find information on the web"`
+	MaxResults int    `json:"max_results,omitempty" description:"Maximum number of results to return (default: 10, max: 20)"`
+}
+
 // FetchParams defines the parameters for the simple fetch tool.
 type FetchParams struct {
 	URL     string `json:"url" description:"The URL to fetch content from"`
diff --git a/internal/agent/tools/ls.go b/internal/agent/tools/ls.go
index 7725910c..2a662774 100644
--- a/internal/agent/tools/ls.go
+++ b/internal/agent/tools/ls.go
@@ -98,7 +98,7 @@ func NewLsTool(permissions permission.Service, workingDir string, lsConfig confi
 
 			output, metadata, err := ListDirectoryTree(searchPath, params, lsConfig)
 			if err != nil {
-				return fantasy.NewTextErrorResponse(err.Error()), err
+				return fantasy.NewTextErrorResponse(err.Error()), nil
 			}
 
 			return fantasy.WithResponseMetadata(
diff --git a/internal/agent/tools/mcp-tools.go b/internal/agent/tools/mcp-tools.go
index e75a6ccb..5b4302cc 100644
--- a/internal/agent/tools/mcp-tools.go
+++ b/internal/agent/tools/mcp-tools.go
@@ -104,9 +104,27 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe
 		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
 	}
 
-	content, err := mcp.RunTool(ctx, m.mcpName, m.tool.Name, params.Input)
+	result, 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
+
+	switch result.Type {
+	case "image", "media":
+		if !GetSupportsImagesFromContext(ctx) {
+			modelName := GetModelNameFromContext(ctx)
+			return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
+		}
+
+		var response fantasy.ToolResponse
+		if result.Type == "image" {
+			response = fantasy.NewImageResponse(result.Data, result.MediaType)
+		} else {
+			response = fantasy.NewMediaResponse(result.Data, result.MediaType)
+		}
+		response.Content = result.Content
+		return response, nil
+	default:
+		return fantasy.NewTextResponse(result.Content), nil
+	}
 }
diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go
index 734c95e6..6ad77bce 100644
--- a/internal/agent/tools/mcp/init.go
+++ b/internal/agent/tools/mcp/init.go
@@ -109,14 +109,26 @@ func GetState(name string) (ClientInfo, bool) {
 // Close closes all MCP clients. This should be called during application shutdown.
 func Close() error {
 	var errs []error
+	var wg sync.WaitGroup
 	for name, session := range sessions.Seq2() {
-		if err := session.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))
-		}
+		wg.Go(func() {
+			done := make(chan bool, 1)
+			go func() {
+				if err := session.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))
+				}
+				done <- true
+			}()
+			select {
+			case <-done:
+			case <-time.After(time.Millisecond * 250):
+			}
+		})
 	}
+	wg.Wait()
 	broker.Shutdown()
 	return errors.Join(errs...)
 }
@@ -279,9 +291,8 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 				})
 			},
 			LoggingMessageHandler: func(_ context.Context, req *mcp.LoggingMessageRequest) {
-				slog.Info("mcp log", "name", name, "data", req.Params.Data)
+				slog.Info("MCP log", "name", name, "data", req.Params.Data)
 			},
-			KeepAlive: time.Minute * 10,
 		},
 	)
 
@@ -289,14 +300,14 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 	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)
+		slog.Error("MCP client failed to initialize", "error", err, "name", name)
 		cancel()
 		cancelTimer.Stop()
 		return nil, err
 	}
 
 	cancelTimer.Stop()
-	slog.Info("Initialized mcp client", "name", name)
+	slog.Info("MCP client initialized", "name", name)
 	return session, nil
 }
 
diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go
index 96aa5411..3a874aa8 100644
--- a/internal/agent/tools/mcp/tools.go
+++ b/internal/agent/tools/mcp/tools.go
@@ -14,6 +14,14 @@ import (
 
 type Tool = mcp.Tool
 
+// ToolResult represents the result of running an MCP tool.
+type ToolResult struct {
+	Type      string
+	Content   string
+	Data      []byte
+	MediaType string
+}
+
 var allTools = csync.NewMap[string, []*Tool]()
 
 // Tools returns all available MCP tools.
@@ -22,33 +30,78 @@ func Tools() iter.Seq2[string, []*Tool] {
 }
 
 // RunTool runs an MCP tool with the given input parameters.
-func RunTool(ctx context.Context, name, toolName string, input string) (string, error) {
+func RunTool(ctx context.Context, name, toolName string, input string) (ToolResult, error) {
 	var args map[string]any
 	if err := json.Unmarshal([]byte(input), &args); err != nil {
-		return "", fmt.Errorf("error parsing parameters: %s", err)
+		return ToolResult{}, fmt.Errorf("error parsing parameters: %s", err)
 	}
 
 	c, err := getOrRenewClient(ctx, name)
 	if err != nil {
-		return "", err
+		return ToolResult{}, err
 	}
 	result, err := c.CallTool(ctx, &mcp.CallToolParams{
 		Name:      toolName,
 		Arguments: args,
 	})
 	if err != nil {
-		return "", err
+		return ToolResult{}, err
+	}
+
+	if len(result.Content) == 0 {
+		return ToolResult{Type: "text", Content: ""}, nil
 	}
 
-	output := make([]string, 0, len(result.Content))
+	var textParts []string
+	var imageData []byte
+	var imageMimeType string
+	var audioData []byte
+	var audioMimeType string
+
 	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))
+		switch content := v.(type) {
+		case *mcp.TextContent:
+			textParts = append(textParts, content.Text)
+		case *mcp.ImageContent:
+			if imageData == nil {
+				imageData = content.Data
+				imageMimeType = content.MIMEType
+			}
+		case *mcp.AudioContent:
+			if audioData == nil {
+				audioData = content.Data
+				audioMimeType = content.MIMEType
+			}
+		default:
+			textParts = append(textParts, fmt.Sprintf("%v", v))
 		}
 	}
-	return strings.Join(output, "\n"), nil
+
+	textContent := strings.Join(textParts, "\n")
+
+	// MCP SDK returns Data as already base64-encoded, so we use it directly.
+	if imageData != nil {
+		return ToolResult{
+			Type:      "image",
+			Content:   textContent,
+			Data:      imageData,
+			MediaType: imageMimeType,
+		}, nil
+	}
+
+	if audioData != nil {
+		return ToolResult{
+			Type:      "media",
+			Content:   textContent,
+			Data:      audioData,
+			MediaType: audioMimeType,
+		}, nil
+	}
+
+	return ToolResult{
+		Type:    "text",
+		Content: textContent,
+	}, nil
 }
 
 // RefreshTools gets the updated list of tools from the MCP and updates the
diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go
index 1956653f..c4a3aa20 100644
--- a/internal/agent/tools/multiedit.go
+++ b/internal/agent/tools/multiedit.go
@@ -165,13 +165,20 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
 	// Check permissions
 	_, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
 
+	editsApplied := len(params.Edits) - len(failedEdits)
+	var description string
+	if len(failedEdits) > 0 {
+		description = fmt.Sprintf("Create file %s with %d of %d edits (%d failed)", params.FilePath, editsApplied, len(params.Edits), len(failedEdits))
+	} else {
+		description = fmt.Sprintf("Create file %s with %d edits", params.FilePath, editsApplied)
+	}
 	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)),
+		Description: description,
 		Params: MultiEditPermissionsParams{
 			FilePath:   params.FilePath,
 			OldContent: "",
@@ -202,7 +209,6 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
 	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))
@@ -299,13 +305,21 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 
 	// Generate diff and check permissions
 	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
+
+	editsApplied := len(params.Edits) - len(failedEdits)
+	var description string
+	if len(failedEdits) > 0 {
+		description = fmt.Sprintf("Apply %d of %d edits to file %s (%d failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
+	} else {
+		description = fmt.Sprintf("Apply %d edits to file %s", editsApplied, params.FilePath)
+	}
 	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),
+		Description: description,
 		Params: MultiEditPermissionsParams{
 			FilePath:   params.FilePath,
 			OldContent: oldContent,
@@ -351,7 +365,6 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 	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))
diff --git a/internal/agent/tools/search.go b/internal/agent/tools/search.go
new file mode 100644
index 00000000..6c5aeb4a
--- /dev/null
+++ b/internal/agent/tools/search.go
@@ -0,0 +1,183 @@
+package tools
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"slices"
+	"strings"
+
+	"golang.org/x/net/html"
+)
+
+// SearchResult represents a single search result from DuckDuckGo.
+type SearchResult struct {
+	Title    string
+	Link     string
+	Snippet  string
+	Position int
+}
+
+// searchDuckDuckGo performs a web search using DuckDuckGo's HTML endpoint.
+func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, maxResults int) ([]SearchResult, error) {
+	if maxResults <= 0 {
+		maxResults = 10
+	}
+
+	formData := url.Values{}
+	formData.Set("q", query)
+	formData.Set("b", "")
+	formData.Set("kl", "")
+
+	req, err := http.NewRequestWithContext(ctx, "POST", "https://html.duckduckgo.com/html", strings.NewReader(formData.Encode()))
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("User-Agent", BrowserUserAgent)
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to execute search: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("search failed with status code: %d", resp.StatusCode)
+	}
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read response: %w", err)
+	}
+
+	return parseSearchResults(string(body), maxResults)
+}
+
+// parseSearchResults extracts search results from DuckDuckGo HTML response.
+func parseSearchResults(htmlContent string, maxResults int) ([]SearchResult, error) {
+	doc, err := html.Parse(strings.NewReader(htmlContent))
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse HTML: %w", err)
+	}
+
+	var results []SearchResult
+	var traverse func(*html.Node)
+
+	traverse = func(n *html.Node) {
+		if n.Type == html.ElementNode && n.Data == "div" && hasClass(n, "result") {
+			result := extractResult(n)
+			if result != nil && result.Link != "" && !strings.Contains(result.Link, "y.js") {
+				result.Position = len(results) + 1
+				results = append(results, *result)
+				if len(results) >= maxResults {
+					return
+				}
+			}
+		}
+		for c := n.FirstChild; c != nil && len(results) < maxResults; c = c.NextSibling {
+			traverse(c)
+		}
+	}
+
+	traverse(doc)
+	return results, nil
+}
+
+// hasClass checks if an HTML node has a specific class.
+func hasClass(n *html.Node, class string) bool {
+	for _, attr := range n.Attr {
+		if attr.Key == "class" {
+			return slices.Contains(strings.Fields(attr.Val), class)
+		}
+	}
+	return false
+}
+
+// extractResult extracts a search result from a result div node.
+func extractResult(n *html.Node) *SearchResult {
+	result := &SearchResult{}
+
+	var traverse func(*html.Node)
+	traverse = func(node *html.Node) {
+		if node.Type == html.ElementNode {
+			// Look for title link.
+			if node.Data == "a" && hasClass(node, "result__a") {
+				result.Title = getTextContent(node)
+				for _, attr := range node.Attr {
+					if attr.Key == "href" {
+						result.Link = cleanDuckDuckGoURL(attr.Val)
+						break
+					}
+				}
+			}
+			// Look for snippet.
+			if node.Data == "a" && hasClass(node, "result__snippet") {
+				result.Snippet = getTextContent(node)
+			}
+		}
+		for c := node.FirstChild; c != nil; c = c.NextSibling {
+			traverse(c)
+		}
+	}
+
+	traverse(n)
+	return result
+}
+
+// getTextContent extracts all text content from a node and its children.
+func getTextContent(n *html.Node) string {
+	var text strings.Builder
+	var traverse func(*html.Node)
+
+	traverse = func(node *html.Node) {
+		if node.Type == html.TextNode {
+			text.WriteString(node.Data)
+		}
+		for c := node.FirstChild; c != nil; c = c.NextSibling {
+			traverse(c)
+		}
+	}
+
+	traverse(n)
+	return strings.TrimSpace(text.String())
+}
+
+// cleanDuckDuckGoURL extracts the actual URL from DuckDuckGo's redirect URL.
+func cleanDuckDuckGoURL(rawURL string) string {
+	if strings.HasPrefix(rawURL, "//duckduckgo.com/l/?uddg=") {
+		// Extract the actual URL from the redirect.
+		if idx := strings.Index(rawURL, "uddg="); idx != -1 {
+			encoded := rawURL[idx+5:]
+			if ampIdx := strings.Index(encoded, "&"); ampIdx != -1 {
+				encoded = encoded[:ampIdx]
+			}
+			decoded, err := url.QueryUnescape(encoded)
+			if err == nil {
+				return decoded
+			}
+		}
+	}
+	return rawURL
+}
+
+// formatSearchResults formats search results for LLM consumption.
+func formatSearchResults(results []SearchResult) string {
+	if len(results) == 0 {
+		return "No results were found for your search query. This could be due to DuckDuckGo's bot detection or the query returned no matches. Please try rephrasing your search or try again in a few minutes."
+	}
+
+	var sb strings.Builder
+	sb.WriteString(fmt.Sprintf("Found %d search results:\n\n", len(results)))
+
+	for _, result := range results {
+		sb.WriteString(fmt.Sprintf("%d. %s\n", result.Position, result.Title))
+		sb.WriteString(fmt.Sprintf("   URL: %s\n", result.Link))
+		sb.WriteString(fmt.Sprintf("   Summary: %s\n\n", result.Snippet))
+	}
+
+	return sb.String()
+}
diff --git a/internal/agent/tools/todos.go b/internal/agent/tools/todos.go
new file mode 100644
index 00000000..eb110420
--- /dev/null
+++ b/internal/agent/tools/todos.go
@@ -0,0 +1,134 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/session"
+)
+
+//go:embed todos.md
+var todosDescription []byte
+
+const TodosToolName = "todos"
+
+type TodosParams struct {
+	Todos []TodoItem `json:"todos" description:"The updated todo list"`
+}
+
+type TodoItem struct {
+	Content    string `json:"content" description:"What needs to be done (imperative form)"`
+	Status     string `json:"status" description:"Task status: pending, in_progress, or completed"`
+	ActiveForm string `json:"active_form" description:"Present continuous form (e.g., 'Running tests')"`
+}
+
+type TodosResponseMetadata struct {
+	IsNew         bool           `json:"is_new"`
+	Todos         []session.Todo `json:"todos"`
+	JustCompleted []string       `json:"just_completed,omitempty"`
+	JustStarted   string         `json:"just_started,omitempty"`
+	Completed     int            `json:"completed"`
+	Total         int            `json:"total"`
+}
+
+func NewTodosTool(sessions session.Service) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		TodosToolName,
+		string(todosDescription),
+		func(ctx context.Context, params TodosParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for managing todos")
+			}
+
+			currentSession, err := sessions.Get(ctx, sessionID)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to get session: %w", err)
+			}
+
+			isNew := len(currentSession.Todos) == 0
+			oldStatusByContent := make(map[string]session.TodoStatus)
+			for _, todo := range currentSession.Todos {
+				oldStatusByContent[todo.Content] = todo.Status
+			}
+
+			for _, item := range params.Todos {
+				switch item.Status {
+				case "pending", "in_progress", "completed":
+				default:
+					return fantasy.ToolResponse{}, fmt.Errorf("invalid status %q for todo %q", item.Status, item.Content)
+				}
+			}
+
+			todos := make([]session.Todo, len(params.Todos))
+			var justCompleted []string
+			var justStarted string
+			completedCount := 0
+
+			for i, item := range params.Todos {
+				todos[i] = session.Todo{
+					Content:    item.Content,
+					Status:     session.TodoStatus(item.Status),
+					ActiveForm: item.ActiveForm,
+				}
+
+				newStatus := session.TodoStatus(item.Status)
+				oldStatus, existed := oldStatusByContent[item.Content]
+
+				if newStatus == session.TodoStatusCompleted {
+					completedCount++
+					if existed && oldStatus != session.TodoStatusCompleted {
+						justCompleted = append(justCompleted, item.Content)
+					}
+				}
+
+				if newStatus == session.TodoStatusInProgress {
+					if !existed || oldStatus != session.TodoStatusInProgress {
+						if item.ActiveForm != "" {
+							justStarted = item.ActiveForm
+						} else {
+							justStarted = item.Content
+						}
+					}
+				}
+			}
+
+			currentSession.Todos = todos
+			_, err = sessions.Save(ctx, currentSession)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to save todos: %w", err)
+			}
+
+			response := "Todo list updated successfully.\n\n"
+
+			pendingCount := 0
+			inProgressCount := 0
+
+			for _, todo := range todos {
+				switch todo.Status {
+				case session.TodoStatusPending:
+					pendingCount++
+				case session.TodoStatusInProgress:
+					inProgressCount++
+				}
+			}
+
+			response += fmt.Sprintf("Status: %d pending, %d in progress, %d completed\n",
+				pendingCount, inProgressCount, completedCount)
+
+			response += "Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable."
+
+			metadata := TodosResponseMetadata{
+				IsNew:         isNew,
+				Todos:         todos,
+				JustCompleted: justCompleted,
+				JustStarted:   justStarted,
+				Completed:     completedCount,
+				Total:         len(todos),
+			}
+
+			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
+		})
+}
diff --git a/internal/agent/tools/todos.md b/internal/agent/tools/todos.md
new file mode 100644
index 00000000..177c5e60
--- /dev/null
+++ b/internal/agent/tools/todos.md
@@ -0,0 +1,90 @@
+Creates and manages a structured task list for tracking progress on complex, multi-step coding tasks.
+
+<when_to_use>
+Use this tool proactively in these scenarios:
+
+- Complex multi-step tasks requiring 3+ distinct steps or actions
+- Non-trivial tasks requiring careful planning or multiple operations
+- User explicitly requests todo list management
+- User provides multiple tasks (numbered or comma-separated list)
+- After receiving new instructions to capture requirements
+- When starting work on a task (mark as in_progress BEFORE beginning)
+- After completing a task (mark completed and add new follow-up tasks)
+</when_to_use>
+
+<when_not_to_use>
+Skip this tool when:
+
+- Single, straightforward task
+- Trivial task with no organizational benefit
+- Task completable in less than 3 trivial steps
+- Purely conversational or informational request
+</when_not_to_use>
+
+<task_states>
+- **pending**: Task not yet started
+- **in_progress**: Currently working on (limit to ONE task at a time)
+- **completed**: Task finished successfully
+
+**IMPORTANT**: Each task requires two forms:
+- **content**: Imperative form describing what needs to be done (e.g., "Run tests", "Build the project")
+- **active_form**: Present continuous form shown during execution (e.g., "Running tests", "Building the project")
+</task_states>
+
+<task_management>
+- Update task status in real-time as you work
+- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
+- Exactly ONE task must be in_progress at any time (not less, not more)
+- Complete current tasks before starting new ones
+- Remove tasks that are no longer relevant from the list entirely
+</task_management>
+
+<completion_requirements>
+ONLY mark a task as completed when you have FULLY accomplished it.
+
+Never mark completed if:
+- Tests are failing
+- Implementation is partial
+- You encountered unresolved errors
+- You couldn't find necessary files or dependencies
+
+If blocked:
+- Keep task as in_progress
+- Create new task describing what needs to be resolved
+</completion_requirements>
+
+<task_breakdown>
+- Create specific, actionable items
+- Break complex tasks into smaller, manageable steps
+- Use clear, descriptive task names
+- Always provide both content and active_form
+</task_breakdown>
+
+<examples>
+✅ Good task:
+```json
+{
+  "content": "Implement user authentication with JWT tokens",
+  "status": "in_progress",
+  "active_form": "Implementing user authentication with JWT tokens"
+}
+```
+
+❌ Bad task (missing active_form):
+```json
+{
+  "content": "Fix bug",
+  "status": "pending"
+}
+```
+</examples>
+
+<output_behavior>
+**NEVER** print or list todos in your response text. The user sees the todo list in real-time in the UI.
+</output_behavior>
+
+<tips>
+- When in doubt, use this tool - being proactive demonstrates attentiveness
+- One task in_progress at a time keeps work focused
+- Update immediately after state changes for accurate tracking
+</tips>
diff --git a/internal/agent/tools/tools.go b/internal/agent/tools/tools.go
index 365d7a33..7d03d0e2 100644
--- a/internal/agent/tools/tools.go
+++ b/internal/agent/tools/tools.go
@@ -7,13 +7,22 @@ import (
 type (
 	sessionIDContextKey string
 	messageIDContextKey string
+	supportsImagesKey   string
+	modelNameKey        string
 )
 
 const (
+	// SessionIDContextKey is the key for the session ID in the context.
 	SessionIDContextKey sessionIDContextKey = "session_id"
+	// MessageIDContextKey is the key for the message ID in the context.
 	MessageIDContextKey messageIDContextKey = "message_id"
+	// SupportsImagesContextKey is the key for the model's image support capability.
+	SupportsImagesContextKey supportsImagesKey = "supports_images"
+	// ModelNameContextKey is the key for the model name in the context.
+	ModelNameContextKey modelNameKey = "model_name"
 )
 
+// GetSessionFromContext retrieves the session ID from the context.
 func GetSessionFromContext(ctx context.Context) string {
 	sessionID := ctx.Value(SessionIDContextKey)
 	if sessionID == nil {
@@ -26,6 +35,7 @@ func GetSessionFromContext(ctx context.Context) string {
 	return s
 }
 
+// GetMessageFromContext retrieves the message ID from the context.
 func GetMessageFromContext(ctx context.Context) string {
 	messageID := ctx.Value(MessageIDContextKey)
 	if messageID == nil {
@@ -37,3 +47,28 @@ func GetMessageFromContext(ctx context.Context) string {
 	}
 	return s
 }
+
+// GetSupportsImagesFromContext retrieves whether the model supports images from the context.
+func GetSupportsImagesFromContext(ctx context.Context) bool {
+	supportsImages := ctx.Value(SupportsImagesContextKey)
+	if supportsImages == nil {
+		return false
+	}
+	if supports, ok := supportsImages.(bool); ok {
+		return supports
+	}
+	return false
+}
+
+// GetModelNameFromContext retrieves the model name from the context.
+func GetModelNameFromContext(ctx context.Context) string {
+	modelName := ctx.Value(ModelNameContextKey)
+	if modelName == nil {
+		return ""
+	}
+	s, ok := modelName.(string)
+	if !ok {
+		return ""
+	}
+	return s
+}
diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go
index 39a2e9d1..3f9db049 100644
--- a/internal/agent/tools/view.go
+++ b/internal/agent/tools/view.go
@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"context"
 	_ "embed"
+	"encoding/base64"
 	"fmt"
 	"io"
 	"os"
@@ -46,7 +47,7 @@ type ViewResponseMetadata struct {
 
 const (
 	ViewToolName     = "view"
-	MaxReadSize      = 250 * 1024
+	MaxReadSize      = 5 * 1024 * 1024 // 5MB
 	DefaultReadLimit = 2000
 	MaxLineLength    = 2000
 )
@@ -147,11 +148,20 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
 				params.Limit = DefaultReadLimit
 			}
 
-			// Check if it's an image file
-			isImage, imageType := isImageFile(filePath)
-			// TODO: handle images
+			isImage, mimeType := getImageMimeType(filePath)
 			if isImage {
-				return fantasy.NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
+				if !GetSupportsImagesFromContext(ctx) {
+					modelName := GetModelNameFromContext(ctx)
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
+				}
+
+				imageData, err := os.ReadFile(filePath)
+				if err != nil {
+					return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", err)
+				}
+
+				encoded := base64.StdEncoding.EncodeToString(imageData)
+				return fantasy.NewImageResponse([]byte(encoded), mimeType), nil
 			}
 
 			// Read the file content
@@ -263,21 +273,21 @@ func readTextFile(filePath string, offset, limit int) (string, int, error) {
 	return strings.Join(lines, "\n"), lineCount, nil
 }
 
-func isImageFile(filePath string) (bool, string) {
+func getImageMimeType(filePath string) (bool, string) {
 	ext := strings.ToLower(filepath.Ext(filePath))
 	switch ext {
 	case ".jpg", ".jpeg":
-		return true, "JPEG"
+		return true, "image/jpeg"
 	case ".png":
-		return true, "PNG"
+		return true, "image/png"
 	case ".gif":
-		return true, "GIF"
+		return true, "image/gif"
 	case ".bmp":
-		return true, "BMP"
+		return true, "image/bmp"
 	case ".svg":
-		return true, "SVG"
+		return true, "image/svg+xml"
 	case ".webp":
-		return true, "WebP"
+		return true, "image/webp"
 	default:
 		return false, ""
 	}
diff --git a/internal/agent/tools/view.md b/internal/agent/tools/view.md
index 8a71559c..740e7cd3 100644
--- a/internal/agent/tools/view.md
+++ b/internal/agent/tools/view.md
@@ -5,6 +5,7 @@ Reads and displays file contents with line numbers for examining code, logs, or
 - 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)
+- Supports image files (PNG, JPEG, GIF, BMP, SVG, WebP)
 </usage>
 
 <features>
@@ -13,13 +14,14 @@ Reads and displays file contents with line numbers for examining code, logs, or
 - Handles large files by limiting lines read
 - Auto-truncates very long lines for display
 - Suggests similar filenames when file not found
+- Renders image files directly in terminal
 </features>
 
 <limitations>
-- Max file size: 250KB
+- Max file size: 5MB
 - Default limit: 2000 lines
 - Lines >2000 chars truncated
-- Cannot display binary files/images (identifies them)
+- Binary files (except images) cannot be displayed
 </limitations>
 
 <cross_platform>
@@ -32,4 +34,5 @@ Reads and displays file contents with line numbers for examining code, logs, or
 - 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
+- View tool automatically detects and renders image files
 </tips>
diff --git a/internal/agent/tools/web_search.go b/internal/agent/tools/web_search.go
new file mode 100644
index 00000000..b604c905
--- /dev/null
+++ b/internal/agent/tools/web_search.go
@@ -0,0 +1,51 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"net/http"
+	"time"
+
+	"charm.land/fantasy"
+)
+
+//go:embed web_search.md
+var webSearchToolDescription []byte
+
+// NewWebSearchTool creates a web search tool for sub-agents (no permissions needed).
+func NewWebSearchTool(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.NewParallelAgentTool(
+		WebSearchToolName,
+		string(webSearchToolDescription),
+		func(ctx context.Context, params WebSearchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.Query == "" {
+				return fantasy.NewTextErrorResponse("query is required"), nil
+			}
+
+			maxResults := params.MaxResults
+			if maxResults <= 0 {
+				maxResults = 10
+			}
+			if maxResults > 20 {
+				maxResults = 20
+			}
+
+			results, err := searchDuckDuckGo(ctx, client, params.Query, maxResults)
+			if err != nil {
+				return fantasy.NewTextErrorResponse("Failed to search: " + err.Error()), nil
+			}
+
+			return fantasy.NewTextResponse(formatSearchResults(results)), nil
+		})
+}
diff --git a/internal/agent/tools/web_search.md b/internal/agent/tools/web_search.md
new file mode 100644
index 00000000..e00a1061
--- /dev/null
+++ b/internal/agent/tools/web_search.md
@@ -0,0 +1,18 @@
+Searches the web using DuckDuckGo and returns search results.
+
+<usage>
+- Provide a search query to find information on the web
+- Returns a list of search results with titles, URLs, and snippets
+- Use this to find relevant web pages, then use web_fetch to get full content
+</usage>
+
+<parameters>
+- query: The search query string (required)
+- max_results: Maximum number of results to return (default: 10, max: 20)
+</parameters>
+
+<tips>
+- Use specific, targeted search queries for better results
+- After getting results, use web_fetch to get the full content of relevant pages
+- Combine multiple searches to gather comprehensive information
+</tips>
diff --git a/internal/app/app.go b/internal/app/app.go
index 8519f258..7e64eaf7 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -10,6 +10,7 @@ import (
 	"io"
 	"log/slog"
 	"os"
+	"strings"
 	"sync"
 	"time"
 
@@ -30,13 +31,13 @@ import (
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/shell"
-	"github.com/charmbracelet/crush/internal/term"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/update"
 	"github.com/charmbracelet/crush/internal/version"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/charmtone"
+	"github.com/charmbracelet/x/term"
 )
 
 type App struct {
@@ -61,7 +62,7 @@ type App struct {
 	cleanupFuncs []func() error
 }
 
-// New initializes a new applcation instance.
+// New initializes a new application instance.
 func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 	q := db.New(conn)
 	sessions := session.NewService(q)
@@ -129,15 +130,27 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
 	ctx, cancel := context.WithCancel(ctx)
 	defer cancel()
 
-	var spinner *format.Spinner
-	if !quiet {
+	var (
+		spinner   *format.Spinner
+		stdoutTTY bool
+		stderrTTY bool
+		stdinTTY  bool
+	)
+
+	if f, ok := output.(*os.File); ok {
+		stdoutTTY = term.IsTerminal(f.Fd())
+	}
+	stderrTTY = term.IsTerminal(os.Stderr.Fd())
+	stdinTTY = term.IsTerminal(os.Stdin.Fd())
+
+	if !quiet && stderrTTY {
 		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 {
+		if f, ok := output.(*os.File); ok && stdinTTY && stdoutTTY {
 			hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, f)
 		}
 		defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
@@ -203,10 +216,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
 
 	messageEvents := app.Messages.Subscribe(ctx)
 	messageReadBytes := make(map[string]int)
-	supportsProgressBar := term.SupportsProgressBar()
 
 	defer func() {
-		if supportsProgressBar {
+		if stderrTTY {
 			_, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
 		}
 
@@ -216,9 +228,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
 	}()
 
 	for {
-		if supportsProgressBar {
-			// HACK: Reinitialize the terminal progress bar on every iteration so
-			// it doesn't get hidden by the terminal due to inactivity.
+		if stderrTTY {
+			// 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)
 		}
 
@@ -248,6 +260,11 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
 				}
 
 				part := content[readBytes:]
+				// Trim leading whitespace. Sometimes the LLM includes leading
+				// formatting and intentation, which we don't want here.
+				if readBytes == 0 {
+					part = strings.TrimLeft(part, " \t")
+				}
 				fmt.Fprint(output, part)
 				messageReadBytes[msg.ID] = len(content)
 			}
@@ -260,6 +277,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
 }
 
 func (app *App) UpdateAgentModel(ctx context.Context) error {
+	if app.AgentCoordinator == nil {
+		return fmt.Errorf("agent configuration is missing")
+	}
 	return app.AgentCoordinator.UpdateModels(ctx)
 }
 
@@ -370,30 +390,42 @@ func (app *App) Subscribe(program *tea.Program) {
 
 // Shutdown performs a graceful shutdown of the application.
 func (app *App) Shutdown() {
+	start := time.Now()
+	defer func() { slog.Info("Shutdown took " + time.Since(start).String()) }()
+	var wg sync.WaitGroup
 	if app.AgentCoordinator != nil {
-		app.AgentCoordinator.CancelAll()
+		wg.Go(func() {
+			app.AgentCoordinator.CancelAll()
+		})
 	}
 
 	// Kill all background shells.
-	shell.GetBackgroundShellManager().KillAll()
+	wg.Go(func() {
+		shell.GetBackgroundShellManager().KillAll()
+	})
 
 	// Shutdown all LSP clients.
 	for name, client := range app.LSPClients.Seq2() {
-		shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
-		if err := client.Close(shutdownCtx); err != nil {
-			slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
-		}
-		cancel()
+		wg.Go(func() {
+			shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
+			defer cancel()
+			if err := client.Close(shutdownCtx); err != nil {
+				slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
+			}
+		})
 	}
 
 	// Call call cleanup functions.
 	for _, cleanup := range app.cleanupFuncs {
 		if cleanup != nil {
-			if err := cleanup(); err != nil {
-				slog.Error("Failed to cleanup app properly on shutdown", "error", err)
-			}
+			wg.Go(func() {
+				if err := cleanup(); err != nil {
+					slog.Error("Failed to cleanup app properly on shutdown", "error", err)
+				}
+			})
 		}
 	}
+	wg.Wait()
 }
 
 // checkForUpdates checks for available updates.
diff --git a/internal/app/lsp.go b/internal/app/lsp.go
index f4c26af2..1a1ac86c 100644
--- a/internal/app/lsp.go
+++ b/internal/app/lsp.go
@@ -23,11 +23,11 @@ func (app *App) initLSPClients(ctx context.Context) {
 
 // createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
 func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig) {
-	slog.Info("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
+	slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
 
 	// Check if any root markers exist in the working directory (config now has defaults)
 	if !lsp.HasRootMarkers(app.config.WorkingDir(), config.RootMarkers) {
-		slog.Info("Skipping LSP client - no root markers found", "name", name, "rootMarkers", config.RootMarkers)
+		slog.Debug("Skipping LSP client: no root markers found", "name", name, "rootMarkers", config.RootMarkers)
 		updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
 		return
 	}
@@ -53,7 +53,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
 	// Initialize LSP client.
 	_, err = lspClient.Initialize(initCtx, app.config.WorkingDir())
 	if err != nil {
-		slog.Error("Initialize failed", "name", name, "error", err)
+		slog.Error("LSP client initialization failed", "name", name, "error", err)
 		updateLSPState(name, lsp.StateError, err, lspClient, 0)
 		lspClient.Close(ctx)
 		return
@@ -68,7 +68,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
 		updateLSPState(name, lsp.StateError, err, lspClient, 0)
 	} else {
 		// Server reached a ready state scuccessfully.
-		slog.Info("LSP server is ready", "name", name)
+		slog.Debug("LSP server is ready", "name", name)
 		lspClient.SetServerState(lsp.StateReady)
 		updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
 	}
diff --git a/internal/cmd/logs.go b/internal/cmd/logs.go
index 43720831..4c66d499 100644
--- a/internal/cmd/logs.go
+++ b/internal/cmd/logs.go
@@ -10,9 +10,9 @@ import (
 	"slices"
 	"time"
 
+	"charm.land/log/v2"
 	"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"
diff --git a/internal/cmd/projects.go b/internal/cmd/projects.go
new file mode 100644
index 00000000..15c74783
--- /dev/null
+++ b/internal/cmd/projects.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+	"encoding/json"
+	"os"
+
+	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/table"
+	"github.com/charmbracelet/crush/internal/projects"
+	"github.com/charmbracelet/x/term"
+	"github.com/spf13/cobra"
+)
+
+var projectsCmd = &cobra.Command{
+	Use:   "projects",
+	Short: "List all tracked project directories",
+	Long: `List all directories where Crush has been used.
+This includes the working directory, data directory path, and last accessed time.`,
+	Example: `
+# List all projects in a table
+crush projects
+
+# Output as JSON
+crush projects --json
+  `,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		jsonOutput, _ := cmd.Flags().GetBool("json")
+
+		projectList, err := projects.List()
+		if err != nil {
+			return err
+		}
+
+		if jsonOutput {
+			output := struct {
+				Projects []projects.Project `json:"projects"`
+			}{Projects: projectList}
+
+			data, err := json.Marshal(output)
+			if err != nil {
+				return err
+			}
+			cmd.Println(string(data))
+			return nil
+		}
+
+		if len(projectList) == 0 {
+			cmd.Println("No projects tracked yet.")
+			return nil
+		}
+
+		if term.IsTerminal(os.Stdout.Fd()) {
+			// We're in a TTY: make it fancy.
+			t := table.New().
+				Border(lipgloss.RoundedBorder()).
+				StyleFunc(func(row, col int) lipgloss.Style {
+					return lipgloss.NewStyle().Padding(0, 2)
+				}).
+				Headers("Path", "Data Dir", "Last Accessed")
+
+			for _, p := range projectList {
+				t.Row(p.Path, p.DataDir, p.LastAccessed.Local().Format("2006-01-02 15:04"))
+			}
+			lipgloss.Println(t)
+			return nil
+		}
+
+		// Not a TTY: plain output
+		for _, p := range projectList {
+			cmd.Printf("%s\t%s\t%s\n", p.Path, p.DataDir, p.LastAccessed.Format("2006-01-02T15:04:05Z07:00"))
+		}
+		return nil
+	},
+}
+
+func init() {
+	projectsCmd.Flags().Bool("json", false, "Output as JSON")
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 17c23aea..90d64c7f 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -19,8 +19,8 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/db"
 	"github.com/charmbracelet/crush/internal/event"
+	"github.com/charmbracelet/crush/internal/projects"
 	"github.com/charmbracelet/crush/internal/stringext"
-	termutil "github.com/charmbracelet/crush/internal/term"
 	"github.com/charmbracelet/crush/internal/tui"
 	"github.com/charmbracelet/crush/internal/version"
 	"github.com/charmbracelet/fang"
@@ -41,6 +41,7 @@ func init() {
 	rootCmd.AddCommand(
 		runCmd,
 		dirsCmd,
+		projectsCmd,
 		updateProvidersCmd,
 		logsCmd,
 		schemaCmd,
@@ -152,8 +153,20 @@ func Execute() {
 	}
 }
 
+// supportsProgressBar tries to determine whether the current terminal supports
+// progress bars by looking into environment variables.
+func supportsProgressBar() bool {
+	if !term.IsTerminal(os.Stderr.Fd()) {
+		return false
+	}
+	termProg := os.Getenv("TERM_PROGRAM")
+	_, isWindowsTerminal := os.LookupEnv("WT_SESSION")
+
+	return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
+}
+
 func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
-	if termutil.SupportsProgressBar() {
+	if supportsProgressBar() {
 		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
 		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
 	}
@@ -188,6 +201,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
 		return nil, err
 	}
 
+	// Register this project in the centralized projects list.
+	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
+		slog.Warn("Failed to register project", "error", err)
+		// Non-fatal: continue even if registration fails
+	}
+
 	// Connect to DB; this will also run migrations.
 	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
 	if err != nil {
@@ -228,7 +247,8 @@ func MaybePrependStdin(prompt string) (string, error) {
 	if err != nil {
 		return prompt, err
 	}
-	if fi.Mode()&os.ModeNamedPipe == 0 {
+	// Check if stdin is a named pipe ( | ) or regular file ( < ).
+	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
 		return prompt, nil
 	}
 	bts, err := io.ReadAll(os.Stdin)
diff --git a/internal/cmd/run.go b/internal/cmd/run.go
index d3748d0f..fe214ecb 100644
--- a/internal/cmd/run.go
+++ b/internal/cmd/run.go
@@ -1,11 +1,14 @@
 package cmd
 
 import (
+	"context"
 	"fmt"
 	"log/slog"
 	"os"
+	"os/signal"
 	"strings"
 
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/spf13/cobra"
 )
 
@@ -30,6 +33,10 @@ crush run --quiet "Generate a README for this project"
 	RunE: func(cmd *cobra.Command, args []string) error {
 		quiet, _ := cmd.Flags().GetBool("quiet")
 
+		// Cancel on SIGINT or SIGTERM.
+		ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
+		defer cancel()
+
 		app, err := setupApp(cmd)
 		if err != nil {
 			return err
@@ -52,13 +59,13 @@ crush run --quiet "Generate a README for this project"
 			return fmt.Errorf("no prompt provided")
 		}
 
-		// 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)
+		event.SetInteractive(true)
+		event.AppInitialized()
+
+		return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet)
+	},
+	PostRun: func(cmd *cobra.Command, args []string) {
+		event.AppExited()
 	},
 }
 
diff --git a/internal/cmd/update_providers.go b/internal/cmd/update_providers.go
index 4949c31e..599d2c90 100644
--- a/internal/cmd/update_providers.go
+++ b/internal/cmd/update_providers.go
@@ -31,12 +31,12 @@ crush update-providers embedded
 		// NOTE(@andreynering): We want to skip logging output do stdout here.
 		slog.SetDefault(slog.New(slog.DiscardHandler))
 
-		var pathOrUrl string
+		var pathOrURL string
 		if len(args) > 0 {
-			pathOrUrl = args[0]
+			pathOrURL = args[0]
 		}
 
-		if err := config.UpdateProviders(pathOrUrl); err != nil {
+		if err := config.UpdateProviders(pathOrURL); err != nil {
 			return err
 		}
 
diff --git a/internal/config/config.go b/internal/config/config.go
index 464dc14b..af1222e5 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -95,6 +95,8 @@ type ProviderConfig struct {
 	Type catwalk.Type `json:"type,omitempty" jsonschema:"description=Provider type that determines the API format,enum=openai,enum=openai-compat,enum=anthropic,enum=gemini,enum=azure,enum=vertexai,default=openai"`
 	// The provider's API key.
 	APIKey string `json:"api_key,omitempty" jsonschema:"description=API key for authentication with the provider,example=$OPENAI_API_KEY"`
+	// The original API key template before resolution (for re-resolution on auth errors).
+	APIKeyTemplate string `json:"-"`
 	// OAuthToken for providers that use OAuth2 authentication.
 	OAuthToken *oauth.Token `json:"oauth,omitempty" jsonschema:"description=OAuth2 token for authentication with the provider"`
 	// Marks the provider as disabled.
@@ -142,13 +144,14 @@ const (
 )
 
 type MCPConfig struct {
-	Command  string            `json:"command,omitempty" jsonschema:"description=Command to execute for stdio MCP servers,example=npx"`
-	Env      map[string]string `json:"env,omitempty" jsonschema:"description=Environment variables to set for the MCP server"`
-	Args     []string          `json:"args,omitempty" jsonschema:"description=Arguments to pass to the MCP server command"`
-	Type     MCPType           `json:"type" jsonschema:"required,description=Type of MCP connection,enum=stdio,enum=sse,enum=http,default=stdio"`
-	URL      string            `json:"url,omitempty" jsonschema:"description=URL for HTTP or SSE MCP servers,format=uri,example=http://localhost:3000/mcp"`
-	Disabled bool              `json:"disabled,omitempty" jsonschema:"description=Whether this MCP server is disabled,default=false"`
-	Timeout  int               `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds for MCP server connections,default=15,example=30,example=60,example=120"`
+	Command       string            `json:"command,omitempty" jsonschema:"description=Command to execute for stdio MCP servers,example=npx"`
+	Env           map[string]string `json:"env,omitempty" jsonschema:"description=Environment variables to set for the MCP server"`
+	Args          []string          `json:"args,omitempty" jsonschema:"description=Arguments to pass to the MCP server command"`
+	Type          MCPType           `json:"type" jsonschema:"required,description=Type of MCP connection,enum=stdio,enum=sse,enum=http,default=stdio"`
+	URL           string            `json:"url,omitempty" jsonschema:"description=URL for HTTP or SSE MCP servers,format=uri,example=http://localhost:3000/mcp"`
+	Disabled      bool              `json:"disabled,omitempty" jsonschema:"description=Whether this MCP server is disabled,default=false"`
+	DisabledTools []string          `json:"disabled_tools,omitempty" jsonschema:"description=List of tools from this MCP server to disable,example=get-library-doc"`
+	Timeout       int               `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds for MCP server connections,default=15,example=30,example=60,example=120"`
 
 	// TODO: maybe make it possible to get the value from the env
 	Headers map[string]string `json:"headers,omitempty" jsonschema:"description=HTTP headers for HTTP/SSE MCP servers"`
@@ -219,7 +222,7 @@ type Options struct {
 	DebugLSP                  bool         `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`
 	DisableAutoSummarize      bool         `json:"disable_auto_summarize,omitempty" jsonschema:"description=Disable automatic conversation summarization,default=false"`
 	DataDirectory             string       `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd
-	DisabledTools             []string     `json:"disabled_tools" jsonschema:"description=Tools to disable"`
+	DisabledTools             []string     `json:"disabled_tools,omitempty" jsonschema:"description=List of built-in tools to disable and hide from the agent,example=bash,example=sourcegraph"`
 	DisableProviderAutoUpdate bool         `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
 	Attribution               *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
 	DisableMetrics            bool         `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
@@ -469,6 +472,7 @@ func (c *Config) SetConfigField(key string, value any) error {
 	return nil
 }
 
+// RefreshOAuthToken refreshes the OAuth token for the given provider.
 func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
 	providerConfig, exists := c.Providers.Get(providerID)
 	if !exists {
@@ -479,7 +483,7 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
 		return fmt.Errorf("provider %s does not have an OAuth token", providerID)
 	}
 
-	// Only Anthropic provider uses OAuth for now
+	// Only Anthropic provider uses OAuth for now.
 	if providerID != string(catwalk.InferenceProviderAnthropic) {
 		return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
 	}
@@ -489,7 +493,7 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
 		return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
 	}
 
-	slog.Info("Successfully refreshed OAuth token in background", "provider", providerID)
+	slog.Info("Successfully refreshed OAuth token", "provider", providerID)
 	providerConfig.OAuthToken = newToken
 	providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
 	providerConfig.SetupClaudeCode()
@@ -627,6 +631,7 @@ func allToolNames() []string {
 		"grep",
 		"ls",
 		"sourcegraph",
+		"todos",
 		"view",
 		"write",
 	}
diff --git a/internal/config/load.go b/internal/config/load.go
index 76458611..14dd0f87 100644
--- a/internal/config/load.go
+++ b/internal/config/load.go
@@ -1,7 +1,6 @@
 package config
 
 import (
-	"cmp"
 	"context"
 	"encoding/json"
 	"fmt"
@@ -15,15 +14,14 @@ import (
 	"slices"
 	"strconv"
 	"strings"
+	"testing"
 
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
-	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/log"
-	"github.com/charmbracelet/crush/internal/oauth/claude"
 	powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
 )
 
@@ -189,6 +187,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
 			Name:               p.Name,
 			BaseURL:            p.APIEndpoint,
 			APIKey:             p.APIKey,
+			APIKeyTemplate:     p.APIKey, // Store original template for re-resolution
 			OAuthToken:         config.OAuthToken,
 			Type:               p.Type,
 			Disable:            config.Disable,
@@ -200,25 +199,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
 		}
 
 		if p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil {
-			if config.OAuthToken.IsExpired() {
-				newToken, err := claude.RefreshToken(context.TODO(), config.OAuthToken.RefreshToken)
-				if err == nil {
-					slog.Info("Successfully refreshed Anthropic OAuth token")
-					config.OAuthToken = newToken
-					prepared.OAuthToken = newToken
-					if err := cmp.Or(
-						c.SetConfigField("providers.anthropic.api_key", newToken.AccessToken),
-						c.SetConfigField("providers.anthropic.oauth", newToken),
-					); err != nil {
-						return err
-					}
-				} else {
-					slog.Error("Failed to refresh Anthropic OAuth token", "error", err)
-					event.Error(err)
-				}
-			} else {
-				slog.Info("Using existing non-expired Anthropic OAuth token")
-			}
 			prepared.SetupClaudeCode()
 		}
 
@@ -693,7 +673,7 @@ func hasAWSCredentials(env env.Env) bool {
 		return true
 	}
 
-	if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil {
+	if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil && !testing.Testing() {
 		return true
 	}
 
diff --git a/internal/config/provider.go b/internal/config/provider.go
index 7a98caaa..e9d4dfcc 100644
--- a/internal/config/provider.go
+++ b/internal/config/provider.go
@@ -2,7 +2,9 @@ package config
 
 import (
 	"cmp"
+	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"log/slog"
 	"os"
@@ -17,7 +19,7 @@ import (
 )
 
 type ProviderClient interface {
-	GetProviders() ([]catwalk.Provider, error)
+	GetProviders(context.Context, string) ([]catwalk.Provider, error)
 }
 
 var (
@@ -53,7 +55,7 @@ func saveProvidersInCache(path string, providers []catwalk.Provider) error {
 		return fmt.Errorf("failed to create directory for provider cache: %w", err)
 	}
 
-	data, err := json.MarshalIndent(providers, "", "  ")
+	data, err := json.Marshal(providers)
 	if err != nil {
 		return fmt.Errorf("failed to marshal provider data: %w", err)
 	}
@@ -64,34 +66,35 @@ func saveProvidersInCache(path string, providers []catwalk.Provider) error {
 	return nil
 }
 
-func loadProvidersFromCache(path string) ([]catwalk.Provider, error) {
+func loadProvidersFromCache(path string) ([]catwalk.Provider, string, error) {
 	data, err := os.ReadFile(path)
 	if err != nil {
-		return nil, fmt.Errorf("failed to read provider cache file: %w", err)
+		return nil, "", fmt.Errorf("failed to read provider cache file: %w", err)
 	}
 
 	var providers []catwalk.Provider
 	if err := json.Unmarshal(data, &providers); err != nil {
-		return nil, fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
+		return nil, "", fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
 	}
-	return providers, nil
+
+	return providers, catwalk.Etag(data), nil
 }
 
-func UpdateProviders(pathOrUrl string) error {
+func UpdateProviders(pathOrURL string) error {
 	var providers []catwalk.Provider
-	pathOrUrl = cmp.Or(pathOrUrl, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
+	pathOrURL = cmp.Or(pathOrURL, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
 
 	switch {
-	case pathOrUrl == "embedded":
+	case pathOrURL == "embedded":
 		providers = embedded.GetAll()
-	case strings.HasPrefix(pathOrUrl, "http://") || strings.HasPrefix(pathOrUrl, "https://"):
+	case strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://"):
 		var err error
-		providers, err = catwalk.NewWithURL(pathOrUrl).GetProviders()
+		providers, err = catwalk.NewWithURL(pathOrURL).GetProviders(context.Background(), "")
 		if err != nil {
 			return fmt.Errorf("failed to fetch providers from Catwalk: %w", err)
 		}
 	default:
-		content, err := os.ReadFile(pathOrUrl)
+		content, err := os.ReadFile(pathOrURL)
 		if err != nil {
 			return fmt.Errorf("failed to read file: %w", err)
 		}
@@ -108,61 +111,61 @@ func UpdateProviders(pathOrUrl string) error {
 		return fmt.Errorf("failed to save providers to cache: %w", err)
 	}
 
-	slog.Info("Providers updated successfully", "count", len(providers), "from", pathOrUrl, "to", cachePath)
+	slog.Info("Providers updated successfully", "count", len(providers), "from", pathOrURL, "to", cachePath)
 	return nil
 }
 
+// Providers returns the list of providers, taking into account cached results
+// and whether or not auto update is enabled.
+//
+// It will:
+// 1. if auto update is disabled, it'll return the embedded providers at the
+// time of release.
+// 2. load the cached providers
+// 3. try to get the fresh list of providers, and return either this new list,
+// the cached list, or the embedded list if all others fail.
 func Providers(cfg *Config) ([]catwalk.Provider, error) {
 	providerOnce.Do(func() {
 		catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
 		client := catwalk.NewWithURL(catwalkURL)
 		path := providerCacheFileData()
 
-		autoUpdateDisabled := cfg.Options.DisableProviderAutoUpdate
-		providerList, providerErr = loadProviders(autoUpdateDisabled, client, path)
-	})
-	return providerList, providerErr
-}
-
-func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) {
-	catwalkGetAndSave := func() ([]catwalk.Provider, error) {
-		providers, err := client.GetProviders()
-		if err != nil {
-			return nil, fmt.Errorf("failed to fetch providers from catwalk: %w", err)
+		if cfg.Options.DisableProviderAutoUpdate {
+			slog.Info("Using embedded Catwalk providers")
+			providerList, providerErr = embedded.GetAll(), nil
+			return
 		}
-		if len(providers) == 0 {
-			return nil, fmt.Errorf("empty providers list from catwalk")
-		}
-		if err := saveProvidersInCache(path, providers); err != nil {
-			return nil, err
-		}
-		return providers, nil
-	}
-
-	switch {
-	case autoUpdateDisabled:
-		slog.Warn("Providers auto-update is disabled")
 
-		if _, err := os.Stat(path); err == nil {
-			slog.Warn("Using locally cached providers")
-			return loadProvidersFromCache(path)
+		cached, etag, cachedErr := loadProvidersFromCache(path)
+		if len(cached) == 0 || cachedErr != nil {
+			// if cached file is empty, default to embedded providers
+			cached = embedded.GetAll()
 		}
 
-		slog.Warn("Saving embedded providers to cache")
-		providers := embedded.GetAll()
-		if err := saveProvidersInCache(path, providers); err != nil {
-			return nil, err
+		providerList, providerErr = loadProviders(client, etag, path)
+		if errors.Is(providerErr, catwalk.ErrNotModified) {
+			slog.Info("Catwalk providers not modified")
+			providerList, providerErr = cached, nil
 		}
-		return providers, nil
-
-	default:
-		slog.Info("Fetching providers from Catwalk.", "path", path)
+	})
+	if providerErr != nil {
+		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.\n\nCause: %w", catwalkURL, providerErr) //nolint:staticcheck
+	}
+	return providerList, nil
+}
 
-		providers, err := catwalkGetAndSave()
-		if err != nil {
-			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 loadProviders(client ProviderClient, etag, path string) ([]catwalk.Provider, error) {
+	slog.Info("Fetching providers from Catwalk.", "path", path)
+	providers, err := client.GetProviders(context.Background(), etag)
+	if err != nil {
+		return nil, fmt.Errorf("failed to fetch providers from catwalk: %w", err)
+	}
+	if len(providers) == 0 {
+		return nil, errors.New("empty providers list from catwalk")
 	}
+	if err := saveProvidersInCache(path, providers); err != nil {
+		return nil, err
+	}
+	return providers, nil
 }
diff --git a/internal/db/migrations/20250812000000_add_todos_to_sessions.sql b/internal/db/migrations/20250812000000_add_todos_to_sessions.sql
new file mode 100644
index 00000000..0a716cbd
--- /dev/null
+++ b/internal/db/migrations/20250812000000_add_todos_to_sessions.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+-- +goose StatementBegin
+ALTER TABLE sessions ADD COLUMN todos TEXT;
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+ALTER TABLE sessions DROP COLUMN todos;
+-- +goose StatementEnd
diff --git a/internal/db/models.go b/internal/db/models.go
index ddced85d..317e7c92 100644
--- a/internal/db/models.go
+++ b/internal/db/models.go
@@ -42,4 +42,5 @@ type Session struct {
 	UpdatedAt        int64          `json:"updated_at"`
 	CreatedAt        int64          `json:"created_at"`
 	SummaryMessageID sql.NullString `json:"summary_message_id"`
+	Todos            sql.NullString `json:"todos"`
 }
diff --git a/internal/db/sessions.sql.go b/internal/db/sessions.sql.go
index 99d31fa2..012e70b4 100644
--- a/internal/db/sessions.sql.go
+++ b/internal/db/sessions.sql.go
@@ -33,7 +33,7 @@ INSERT INTO sessions (
     null,
     strftime('%s', 'now'),
     strftime('%s', 'now')
-) RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id
+) RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id, todos
 `
 
 type CreateSessionParams struct {
@@ -68,6 +68,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S
 		&i.UpdatedAt,
 		&i.CreatedAt,
 		&i.SummaryMessageID,
+		&i.Todos,
 	)
 	return i, err
 }
@@ -83,7 +84,7 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
 }
 
 const getSessionByID = `-- name: GetSessionByID :one
-SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id
+SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id, todos
 FROM sessions
 WHERE id = ? LIMIT 1
 `
@@ -102,15 +103,16 @@ func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error
 		&i.UpdatedAt,
 		&i.CreatedAt,
 		&i.SummaryMessageID,
+		&i.Todos,
 	)
 	return i, err
 }
 
 const listSessions = `-- name: ListSessions :many
-SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id
+SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id, todos
 FROM sessions
 WHERE parent_session_id is NULL
-ORDER BY created_at DESC
+ORDER BY updated_at DESC
 `
 
 func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
@@ -133,6 +135,7 @@ func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
 			&i.UpdatedAt,
 			&i.CreatedAt,
 			&i.SummaryMessageID,
+			&i.Todos,
 		); err != nil {
 			return nil, err
 		}
@@ -154,9 +157,10 @@ SET
     prompt_tokens = ?,
     completion_tokens = ?,
     summary_message_id = ?,
-    cost = ?
+    cost = ?,
+    todos = ?
 WHERE id = ?
-RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id
+RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id, todos
 `
 
 type UpdateSessionParams struct {
@@ -165,6 +169,7 @@ type UpdateSessionParams struct {
 	CompletionTokens int64          `json:"completion_tokens"`
 	SummaryMessageID sql.NullString `json:"summary_message_id"`
 	Cost             float64        `json:"cost"`
+	Todos            sql.NullString `json:"todos"`
 	ID               string         `json:"id"`
 }
 
@@ -175,6 +180,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S
 		arg.CompletionTokens,
 		arg.SummaryMessageID,
 		arg.Cost,
+		arg.Todos,
 		arg.ID,
 	)
 	var i Session
@@ -189,6 +195,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S
 		&i.UpdatedAt,
 		&i.CreatedAt,
 		&i.SummaryMessageID,
+		&i.Todos,
 	)
 	return i, err
 }
diff --git a/internal/db/sql/sessions.sql b/internal/db/sql/sessions.sql
index ebeab90d..9f673059 100644
--- a/internal/db/sql/sessions.sql
+++ b/internal/db/sql/sessions.sql
@@ -32,7 +32,7 @@ WHERE id = ? LIMIT 1;
 SELECT *
 FROM sessions
 WHERE parent_session_id is NULL
-ORDER BY created_at DESC;
+ORDER BY updated_at DESC;
 
 -- name: UpdateSession :one
 UPDATE sessions
@@ -41,7 +41,8 @@ SET
     prompt_tokens = ?,
     completion_tokens = ?,
     summary_message_id = ?,
-    cost = ?
+    cost = ?,
+    todos = ?
 WHERE id = ?
 RETURNING *;
 
diff --git a/internal/env/env.go b/internal/env/env.go
index 24d44d10..fa7546f8 100644
--- a/internal/env/env.go
+++ b/internal/env/env.go
@@ -1,6 +1,9 @@
 package env
 
-import "os"
+import (
+	"os"
+	"testing"
+)
 
 type Env interface {
 	Get(key string) string
@@ -23,6 +26,9 @@ func (o *osEnv) Env() []string {
 }
 
 func New() Env {
+	if testing.Testing() {
+		return NewFromMap(nil)
+	}
 	return &osEnv{}
 }
 
diff --git a/internal/event/event.go b/internal/event/event.go
index ca02c6d8..462e3f3b 100644
--- a/internal/event/event.go
+++ b/internal/event/event.go
@@ -26,9 +26,14 @@ var (
 			Set("TERM", os.Getenv("TERM")).
 			Set("SHELL", filepath.Base(os.Getenv("SHELL"))).
 			Set("Version", version.Version).
-			Set("GoVersion", runtime.Version())
+			Set("GoVersion", runtime.Version()).
+			Set("Interactive", false)
 )
 
+func SetInteractive(interactive bool) {
+	baseProps = baseProps.Set("interactive", interactive)
+}
+
 func Init() {
 	c, err := posthog.NewWithConfig(key, posthog.Config{
 		Endpoint: endpoint,
diff --git a/internal/history/file.go b/internal/history/file.go
index 7317f012..438b4116 100644
--- a/internal/history/file.go
+++ b/internal/history/file.go
@@ -26,7 +26,7 @@ type File struct {
 }
 
 type Service interface {
-	pubsub.Suscriber[File]
+	pubsub.Subscriber[File]
 	Create(ctx context.Context, sessionID, path, content string) (File, error)
 	CreateVersion(ctx context.Context, sessionID, path, content string) (File, error)
 	Get(ctx context.Context, id string) (File, error)
diff --git a/internal/message/message.go b/internal/message/message.go
index 4cdf89b5..97db7fe3 100644
--- a/internal/message/message.go
+++ b/internal/message/message.go
@@ -21,7 +21,7 @@ type CreateMessageParams struct {
 }
 
 type Service interface {
-	pubsub.Suscriber[Message]
+	pubsub.Subscriber[Message]
 	Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error)
 	Update(ctx context.Context, message Message) error
 	Get(ctx context.Context, id string) (Message, error)
diff --git a/internal/permission/permission.go b/internal/permission/permission.go
index e7bc3f65..829dd2ed 100644
--- a/internal/permission/permission.go
+++ b/internal/permission/permission.go
@@ -43,7 +43,7 @@ type PermissionRequest struct {
 }
 
 type Service interface {
-	pubsub.Suscriber[PermissionRequest]
+	pubsub.Subscriber[PermissionRequest]
 	GrantPersistent(permission PermissionRequest)
 	Grant(permission PermissionRequest)
 	Deny(permission PermissionRequest)
diff --git a/internal/projects/projects.go b/internal/projects/projects.go
new file mode 100644
index 00000000..f909fc2c
--- /dev/null
+++ b/internal/projects/projects.go
@@ -0,0 +1,126 @@
+package projects
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"slices"
+	"sync"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+)
+
+const projectsFileName = "projects.json"
+
+// Project represents a tracked project directory.
+type Project struct {
+	Path         string    `json:"path"`
+	DataDir      string    `json:"data_dir"`
+	LastAccessed time.Time `json:"last_accessed"`
+}
+
+// ProjectList holds the list of tracked projects.
+type ProjectList struct {
+	Projects []Project `json:"projects"`
+}
+
+var mu sync.Mutex
+
+// projectsFilePath returns the path to the projects.json file.
+func projectsFilePath() string {
+	return filepath.Join(filepath.Dir(config.GlobalConfigData()), projectsFileName)
+}
+
+// Load reads the projects list from disk.
+func Load() (*ProjectList, error) {
+	mu.Lock()
+	defer mu.Unlock()
+
+	path := projectsFilePath()
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return &ProjectList{Projects: []Project{}}, nil
+		}
+		return nil, err
+	}
+
+	var list ProjectList
+	if err := json.Unmarshal(data, &list); err != nil {
+		return nil, err
+	}
+
+	return &list, nil
+}
+
+// Save writes the projects list to disk.
+func Save(list *ProjectList) error {
+	mu.Lock()
+	defer mu.Unlock()
+
+	path := projectsFilePath()
+
+	// Ensure directory exists
+	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+		return err
+	}
+
+	data, err := json.MarshalIndent(list, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(path, data, 0o600)
+}
+
+// Register adds or updates a project in the list.
+func Register(workingDir, dataDir string) error {
+	list, err := Load()
+	if err != nil {
+		return err
+	}
+
+	now := time.Now().UTC()
+
+	// Check if project already exists
+	found := false
+	for i, p := range list.Projects {
+		if p.Path == workingDir {
+			list.Projects[i].DataDir = dataDir
+			list.Projects[i].LastAccessed = now
+			found = true
+			break
+		}
+	}
+
+	if !found {
+		list.Projects = append(list.Projects, Project{
+			Path:         workingDir,
+			DataDir:      dataDir,
+			LastAccessed: now,
+		})
+	}
+
+	// Sort by last accessed (most recent first)
+	slices.SortFunc(list.Projects, func(a, b Project) int {
+		if a.LastAccessed.After(b.LastAccessed) {
+			return -1
+		}
+		if a.LastAccessed.Before(b.LastAccessed) {
+			return 1
+		}
+		return 0
+	})
+
+	return Save(list)
+}
+
+// List returns all tracked projects sorted by last accessed.
+func List() ([]Project, error) {
+	list, err := Load()
+	if err != nil {
+		return nil, err
+	}
+	return list.Projects, nil
+}
diff --git a/internal/pubsub/events.go b/internal/pubsub/events.go
index dadecaa1..016cc10c 100644
--- a/internal/pubsub/events.go
+++ b/internal/pubsub/events.go
@@ -8,7 +8,7 @@ const (
 	DeletedEvent EventType = "deleted"
 )
 
-type Suscriber[T any] interface {
+type Subscriber[T any] interface {
 	Subscribe(context.Context) <-chan Event[T]
 }
 
diff --git a/internal/session/session.go b/internal/session/session.go
index a1818970..6d5e9a43 100644
--- a/internal/session/session.go
+++ b/internal/session/session.go
@@ -3,7 +3,9 @@ package session
 import (
 	"context"
 	"database/sql"
+	"encoding/json"
 	"fmt"
+	"log/slog"
 	"strings"
 
 	"github.com/charmbracelet/crush/internal/db"
@@ -12,6 +14,20 @@ import (
 	"github.com/google/uuid"
 )
 
+type TodoStatus string
+
+const (
+	TodoStatusPending    TodoStatus = "pending"
+	TodoStatusInProgress TodoStatus = "in_progress"
+	TodoStatusCompleted  TodoStatus = "completed"
+)
+
+type Todo struct {
+	Content    string     `json:"content"`
+	Status     TodoStatus `json:"status"`
+	ActiveForm string     `json:"active_form"`
+}
+
 type Session struct {
 	ID               string
 	ParentSessionID  string
@@ -21,12 +37,13 @@ type Session struct {
 	CompletionTokens int64
 	SummaryMessageID string
 	Cost             float64
+	Todos            []Todo
 	CreatedAt        int64
 	UpdatedAt        int64
 }
 
 type Service interface {
-	pubsub.Suscriber[Session]
+	pubsub.Subscriber[Session]
 	Create(ctx context.Context, title string) (Session, error)
 	CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error)
 	CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error)
@@ -111,6 +128,11 @@ func (s *service) Get(ctx context.Context, id string) (Session, error) {
 }
 
 func (s *service) Save(ctx context.Context, session Session) (Session, error) {
+	todosJSON, err := marshalTodos(session.Todos)
+	if err != nil {
+		return Session{}, err
+	}
+
 	dbSession, err := s.q.UpdateSession(ctx, db.UpdateSessionParams{
 		ID:               session.ID,
 		Title:            session.Title,
@@ -121,6 +143,10 @@ func (s *service) Save(ctx context.Context, session Session) (Session, error) {
 			Valid:  session.SummaryMessageID != "",
 		},
 		Cost: session.Cost,
+		Todos: sql.NullString{
+			String: todosJSON,
+			Valid:  todosJSON != "",
+		},
 	})
 	if err != nil {
 		return Session{}, err
@@ -143,6 +169,10 @@ func (s *service) List(ctx context.Context) ([]Session, error) {
 }
 
 func (s service) fromDBItem(item db.Session) Session {
+	todos, err := unmarshalTodos(item.Todos.String)
+	if err != nil {
+		slog.Error("failed to unmarshal todos", "session_id", item.ID, "error", err)
+	}
 	return Session{
 		ID:               item.ID,
 		ParentSessionID:  item.ParentSessionID.String,
@@ -152,11 +182,34 @@ func (s service) fromDBItem(item db.Session) Session {
 		CompletionTokens: item.CompletionTokens,
 		SummaryMessageID: item.SummaryMessageID.String,
 		Cost:             item.Cost,
+		Todos:            todos,
 		CreatedAt:        item.CreatedAt,
 		UpdatedAt:        item.UpdatedAt,
 	}
 }
 
+func marshalTodos(todos []Todo) (string, error) {
+	if len(todos) == 0 {
+		return "", nil
+	}
+	data, err := json.Marshal(todos)
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+func unmarshalTodos(data string) ([]Todo, error) {
+	if data == "" {
+		return []Todo{}, nil
+	}
+	var todos []Todo
+	if err := json.Unmarshal([]byte(data), &todos); err != nil {
+		return []Todo{}, err
+	}
+	return todos, nil
+}
+
 func NewService(q db.Querier) Service {
 	broker := pubsub.NewBroker[Session]()
 	return &service{
diff --git a/internal/term/term.go b/internal/term/term.go
deleted file mode 100644
index 8f86ea24..00000000
--- a/internal/term/term.go
+++ /dev/null
@@ -1,15 +0,0 @@
-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/chat/chat.go b/internal/tui/components/chat/chat.go
index 6d7660dc..036c8262 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -2,7 +2,6 @@ package chat
 
 import (
 	"context"
-	"strings"
 	"time"
 
 	"charm.land/bubbles/v2/key"
@@ -73,7 +72,6 @@ type messageListCmp struct {
 	lastClickX    int
 	lastClickY    int
 	clickCount    int
-	promptQueue   int
 }
 
 // New creates a new message list component with custom keybindings
@@ -104,13 +102,6 @@ func (m *messageListCmp) Init() tea.Cmd {
 // Update handles incoming messages and updates the component state.
 func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
-	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))
-		}
-	}
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		if m.listCmp.IsFocused() && m.listCmp.HasSelection() {
@@ -223,24 +214,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 // View renders the message list or an initial screen if empty.
 func (m *messageListCmp) View() string {
 	t := styles.CurrentTheme()
-	height := m.height
-	if m.promptQueue > 0 {
-		height -= 4 // pill height and padding
-	}
-	view := []string{
-		t.S().Base.
-			Padding(1, 1, 0, 1).
-			Width(m.width).
-			Height(height).
-			Render(
-				m.listCmp.View(),
-			),
-	}
-	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))
-	}
-	return strings.Join(view, "\n")
+	return t.S().Base.
+		Padding(1, 1, 0, 1).
+		Width(m.width).
+		Height(m.height).
+		Render(m.listCmp.View())
 }
 
 func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
@@ -682,11 +660,6 @@ func (m *messageListCmp) GetSize() (int, int) {
 func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
 	m.width = width
 	m.height = height
-	if m.promptQueue > 0 {
-		queueHeight := 3 + 1 // 1 for padding top
-		lHight := max(0, height-(1+queueHeight))
-		return m.listCmp.SetSize(width-2, lHight)
-	}
 	return m.listCmp.SetSize(width-2, max(0, height-1)) // for padding
 }
 
@@ -783,11 +756,7 @@ func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
 		return util.ReportInfo("No text selected")
 	}
 
-	if clear {
-		defer func() { m.SelectionClear() }()
-	}
-
-	return tea.Sequence(
+	cmds := []tea.Cmd{
 		// We use both OSC 52 and native clipboard for compatibility with different
 		// terminal emulators and environments.
 		tea.SetClipboard(selectedText),
@@ -796,7 +765,12 @@ func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
 			return nil
 		},
 		util.ReportInfo("Selected text copied to clipboard"),
-	)
+	}
+	if clear {
+		cmds = append(cmds, m.SelectionClear())
+	}
+
+	return tea.Sequence(cmds...)
 }
 
 // abs returns the absolute value of an integer.
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index 8ae8ed3d..bd90d90a 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/internal/tui/components/chat/editor/editor.go
@@ -41,6 +41,7 @@ type Editor interface {
 	SetSession(session session.Session) tea.Cmd
 	IsCompletionsOpen() bool
 	HasAttachments() bool
+	IsEmpty() bool
 	Cursor() *tea.Cursor
 }
 
@@ -261,7 +262,7 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		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:
+		case msg.String() == "/" && m.IsEmpty():
 			return m, util.CmdHandler(dialogs.OpenDialogMsg{
 				Model: commands.NewCommandDialog(m.session.ID),
 			})
@@ -542,10 +543,17 @@ func (c *editorCmp) HasAttachments() bool {
 	return len(c.attachments) > 0
 }
 
+func (c *editorCmp) IsEmpty() bool {
+	return strings.TrimSpace(c.textarea.Value()) == ""
+}
+
 func normalPromptFunc(info textarea.PromptInfo) string {
 	t := styles.CurrentTheme()
 	if info.LineNumber == 0 {
-		return "  > "
+		if info.Focused {
+			return "  > "
+		}
+		return "::: "
 	}
 	if info.Focused {
 		return t.S().Base.Foreground(t.GreenDark).Render("::: ")
diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go
index 54f36fbb..38012c23 100644
--- a/internal/tui/components/chat/messages/messages.go
+++ b/internal/tui/components/chat/messages/messages.go
@@ -181,17 +181,18 @@ func (msg *messageCmp) style() lipgloss.Style {
 func (m *messageCmp) renderAssistantMessage() string {
 	t := styles.CurrentTheme()
 	parts := []string{}
-	content := m.message.Content().String()
+	content := strings.TrimSpace(m.message.Content().String())
 	thinking := m.message.IsThinking()
+	thinkingContent := strings.TrimSpace(m.message.ReasoningContent().Thinking)
 	finished := m.message.IsFinished()
 	finishedData := m.message.FinishPart()
-	thinkingContent := ""
 
-	if thinking || strings.TrimSpace(m.message.ReasoningContent().Thinking) != "" {
+	if thinking || thinkingContent != "" {
 		m.anim.SetLabel("Thinking")
 		thinkingContent = m.renderThinkingContent()
 	} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
-		content = ""
+		// Don't render empty assistant messages with EndTurn
+		return ""
 	} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
 		content = "*Canceled*"
 	} else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
@@ -310,7 +311,11 @@ func (m *messageCmp) renderThinkingContent() string {
 		}
 	}
 	lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
-	return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
+	result := lineStyle.Width(m.textWidth()).Padding(0, 1, 0, 0).Render(m.thinkingViewport.View())
+	if footer != "" {
+		result += "\n\n" + footer
+	}
+	return result
 }
 
 // shouldSpin determines whether the message should show a loading animation.
diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go
index 72631d2a..5fbd8a65 100644
--- a/internal/tui/components/chat/messages/renderer.go
+++ b/internal/tui/components/chat/messages/renderer.go
@@ -13,6 +13,7 @@ import (
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/ansiext"
 	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/tui/components/chat/todos"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/highlight"
 	"github.com/charmbracelet/crush/internal/tui/styles"
@@ -52,6 +53,23 @@ var registry = renderRegistry{}
 // baseRenderer provides common functionality for all tool renderers
 type baseRenderer struct{}
 
+func (br baseRenderer) Render(v *toolCallCmp) string {
+	if v.result.Data != "" {
+		if strings.HasPrefix(v.result.MIMEType, "image/") {
+			return br.renderWithParams(v, v.call.Name, nil, func() string {
+				return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content)
+			})
+		}
+		return br.renderWithParams(v, v.call.Name, nil, func() string {
+			return renderMediaContent(v, v.result.MIMEType, v.result.Content)
+		})
+	}
+
+	return br.renderWithParams(v, v.call.Name, nil, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
+}
+
 // paramBuilder helps construct parameter lists for tool headers
 type paramBuilder struct {
 	args []string
@@ -174,11 +192,13 @@ func init() {
 	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.WebSearchToolName, func() renderer { return webSearchRenderer{} })
 	registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
 	registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
 	registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
 	registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
 	registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
+	registry.register(tools.TodosToolName, func() renderer { return todosRenderer{} })
 	registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} })
 }
 
@@ -191,8 +211,18 @@ type genericRenderer struct {
 	baseRenderer
 }
 
-// Render displays the tool call with its raw input and plain content output
 func (gr genericRenderer) Render(v *toolCallCmp) string {
+	if v.result.Data != "" {
+		if strings.HasPrefix(v.result.MIMEType, "image/") {
+			return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
+				return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content)
+			})
+		}
+		return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
+			return renderMediaContent(v, v.result.MIMEType, v.result.Content)
+		})
+	}
+
 	return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
 		return renderPlainContent(v, v.result.Content)
 	})
@@ -408,6 +438,10 @@ func (vr viewRenderer) Render(v *toolCallCmp) string {
 		build()
 
 	return vr.renderWithParams(v, "View", args, func() string {
+		if v.result.Data != "" && strings.HasPrefix(v.result.MIMEType, "image/") {
+			return renderImageContent(v, v.result.Data, v.result.MIMEType, "")
+		}
+
 		var meta tools.ViewResponseMetadata
 		if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil {
 			return renderPlainContent(v, v.result.Content)
@@ -605,15 +639,17 @@ type agenticFetchRenderer struct {
 	baseRenderer
 }
 
-// Render displays the fetched URL with prompt parameter and nested tool calls
+// Render displays the fetched URL or web search 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()
+		if params.URL != "" {
+			args = newParamBuilder().
+				addMain(params.URL).
+				build()
+		}
 	}
 
 	prompt := params.Prompt
@@ -700,6 +736,30 @@ func (wfr webFetchRenderer) Render(v *toolCallCmp) string {
 	})
 }
 
+// -----------------------------------------------------------------------------
+//  Web search renderer
+// -----------------------------------------------------------------------------
+
+// webSearchRenderer handles web search with query display
+type webSearchRenderer struct {
+	baseRenderer
+}
+
+// Render displays a compact view of web_search with just the query
+func (wsr webSearchRenderer) Render(v *toolCallCmp) string {
+	var params tools.WebSearchParams
+	var args []string
+	if err := wsr.unmarshalParams(v.call.Input, &params); err == nil {
+		args = newParamBuilder().
+			addMain(params.Query).
+			build()
+	}
+
+	return wsr.renderWithParams(v, "Search", args, func() string {
+		return renderMarkdownContent(v, v.result.Content)
+	})
+}
+
 // -----------------------------------------------------------------------------
 //  Download renderer
 // -----------------------------------------------------------------------------
@@ -1025,7 +1085,7 @@ func renderPlainContent(v *toolCallCmp, content string) string {
 		}
 		ln = ansiext.Escape(ln)
 		ln = " " + ln
-		if len(ln) > width {
+		if lipgloss.Width(ln) > width {
 			ln = v.fit(ln, width)
 		}
 		out = append(out, t.S().Muted.
@@ -1143,6 +1203,55 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
 	return lipgloss.JoinVertical(lipgloss.Left, lines...)
 }
 
+// renderImageContent renders image data with optional text content (for MCP tools).
+func renderImageContent(v *toolCallCmp, data, mediaType, textContent string) string {
+	t := styles.CurrentTheme()
+
+	dataSize := len(data) * 3 / 4
+	sizeStr := formatSize(dataSize)
+
+	loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
+	arrow := t.S().Base.Foreground(t.GreenDark).Render("→")
+	typeStyled := t.S().Base.Render(mediaType)
+	sizeStyled := t.S().Subtle.Render(sizeStr)
+
+	imageDisplay := fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)
+	if strings.TrimSpace(textContent) != "" {
+		textDisplay := renderPlainContent(v, textContent)
+		return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", imageDisplay)
+	}
+
+	return imageDisplay
+}
+
+// renderMediaContent renders non-image media content.
+func renderMediaContent(v *toolCallCmp, mediaType, textContent string) string {
+	t := styles.CurrentTheme()
+
+	loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
+	arrow := t.S().Base.Foreground(t.GreenDark).Render("→")
+	typeStyled := t.S().Base.Render(mediaType)
+	mediaDisplay := fmt.Sprintf("%s %s %s", loaded, arrow, typeStyled)
+
+	if strings.TrimSpace(textContent) != "" {
+		textDisplay := renderPlainContent(v, textContent)
+		return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", mediaDisplay)
+	}
+
+	return mediaDisplay
+}
+
+// formatSize formats byte count as human-readable size.
+func formatSize(bytes int) string {
+	if bytes < 1024 {
+		return fmt.Sprintf("%d B", bytes)
+	}
+	if bytes < 1024*1024 {
+		return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
+	}
+	return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
+}
+
 func (v *toolCallCmp) renderToolError() string {
 	t := styles.CurrentTheme()
 	err := strings.ReplaceAll(v.result.Content, "\n", " ")
@@ -1180,7 +1289,9 @@ func prettifyToolName(name string) string {
 	case tools.AgenticFetchToolName:
 		return "Agentic Fetch"
 	case tools.WebFetchToolName:
-		return "Fetching"
+		return "Fetch"
+	case tools.WebSearchToolName:
+		return "Search"
 	case tools.GlobToolName:
 		return "Glob"
 	case tools.GrepToolName:
@@ -1189,6 +1300,8 @@ func prettifyToolName(name string) string {
 		return "List"
 	case tools.SourcegraphToolName:
 		return "Sourcegraph"
+	case tools.TodosToolName:
+		return "To-Do"
 	case tools.ViewToolName:
 		return "View"
 	case tools.WriteToolName:
@@ -1197,3 +1310,94 @@ func prettifyToolName(name string) string {
 		return name
 	}
 }
+
+// -----------------------------------------------------------------------------
+//  Todos renderer
+// -----------------------------------------------------------------------------
+
+type todosRenderer struct {
+	baseRenderer
+}
+
+func (tr todosRenderer) Render(v *toolCallCmp) string {
+	t := styles.CurrentTheme()
+	var params tools.TodosParams
+	var meta tools.TodosResponseMetadata
+	var headerText string
+	var body string
+
+	// Parse params for pending state (before result is available).
+	if err := tr.unmarshalParams(v.call.Input, &params); err == nil {
+		completedCount := 0
+		inProgressTask := ""
+		for _, todo := range params.Todos {
+			if todo.Status == "completed" {
+				completedCount++
+			}
+			if todo.Status == "in_progress" {
+				if todo.ActiveForm != "" {
+					inProgressTask = todo.ActiveForm
+				} else {
+					inProgressTask = todo.Content
+				}
+			}
+		}
+
+		// Default display from params (used when pending or no metadata).
+		ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
+		headerText = ratio
+		if inProgressTask != "" {
+			headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask)
+		}
+
+		// If we have metadata, use it for richer display.
+		if v.result.Metadata != "" {
+			if err := tr.unmarshalParams(v.result.Metadata, &meta); err == nil {
+				if meta.IsNew {
+					if meta.JustStarted != "" {
+						headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
+					} else {
+						headerText = fmt.Sprintf("created %d todos", meta.Total)
+					}
+					body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth())
+				} else {
+					// Build header based on what changed.
+					hasCompleted := len(meta.JustCompleted) > 0
+					hasStarted := meta.JustStarted != ""
+					allCompleted := meta.Completed == meta.Total
+
+					ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
+					if hasCompleted && hasStarted {
+						text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted)))
+						headerText = fmt.Sprintf("%s%s", ratio, text)
+					} else if hasCompleted {
+						text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted)))
+						if allCompleted {
+							text = t.S().Subtle.Render(" · completed all")
+						}
+						headerText = fmt.Sprintf("%s%s", ratio, text)
+					} else if hasStarted {
+						headerText = fmt.Sprintf("%s%s", ratio, t.S().Subtle.Render(" · starting task"))
+					} else {
+						headerText = ratio
+					}
+
+					// Build body with details.
+					if allCompleted {
+						// Show all todos when all are completed, like when created
+						body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth())
+					} else if meta.JustStarted != "" {
+						body = t.S().Base.Foreground(t.GreenDark).Render(styles.ArrowRightIcon+" ") +
+							t.S().Base.Foreground(t.FgBase).Render(meta.JustStarted)
+					}
+				}
+			}
+		}
+	}
+
+	args := newParamBuilder().addMain(headerText).build()
+
+	return tr.renderWithParams(v, "To-Do", args, func() string {
+		return body
+	})
+}
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index 123d02e6..b8163f5a 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/internal/tui/components/chat/messages/tool.go
@@ -301,7 +301,9 @@ func (m *toolCallCmp) formatParametersForCopy() string {
 		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.URL != "" {
+				parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
+			}
 			if params.Prompt != "" {
 				parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
 			}
@@ -397,6 +399,13 @@ func (m *toolCallCmp) formatParametersForCopy() string {
 }
 
 func (m *toolCallCmp) formatResultForCopy() string {
+	if m.result.Data != "" {
+		if strings.HasPrefix(m.result.MIMEType, "image/") {
+			return fmt.Sprintf("[Image: %s]", m.result.MIMEType)
+		}
+		return fmt.Sprintf("[Media: %s]", m.result.MIMEType)
+	}
+
 	switch m.call.Name {
 	case tools.BashToolName:
 		return m.formatBashResultForCopy()
@@ -416,7 +425,7 @@ func (m *toolCallCmp) formatResultForCopy() string {
 		return m.formatWebFetchResultForCopy()
 	case agent.AgentToolName:
 		return m.formatAgentResultForCopy()
-	case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName:
+	case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
 		return fmt.Sprintf("```\n%s\n```", m.result.Content)
 	default:
 		return m.result.Content
@@ -790,6 +799,9 @@ func (m *toolCallCmp) textWidth() int {
 
 // fit truncates content to fit within the specified width with ellipsis
 func (m *toolCallCmp) fit(content string, width int) string {
+	if lipgloss.Width(content) <= width {
+		return content
+	}
 	t := styles.CurrentTheme()
 	lineStyle := t.S().Muted
 	dots := lineStyle.Render("…")
diff --git a/internal/tui/components/chat/queue.go b/internal/tui/components/chat/queue.go
deleted file mode 100644
index f35700e2..00000000
--- a/internal/tui/components/chat/queue.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package chat
-
-import (
-	"fmt"
-	"strings"
-
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-)
-
-func queuePill(queue int, t *styles.Theme) string {
-	if queue <= 0 {
-		return ""
-	}
-	triangles := styles.ForegroundGrad("▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Accent)
-	if queue < 10 {
-		triangles = triangles[:queue]
-	}
-
-	allTriangles := strings.Join(triangles, "")
-
-	return t.S().Base.
-		BorderStyle(lipgloss.RoundedBorder()).
-		BorderForeground(t.BgOverlay).
-		PaddingLeft(1).
-		PaddingRight(1).
-		Render(fmt.Sprintf("%s %d Queued", allTriangles, queue))
-}
diff --git a/internal/tui/components/chat/todos/todos.go b/internal/tui/components/chat/todos/todos.go
new file mode 100644
index 00000000..8973e4f4
--- /dev/null
+++ b/internal/tui/components/chat/todos/todos.go
@@ -0,0 +1,67 @@
+package todos
+
+import (
+	"slices"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+func sortTodos(todos []session.Todo) {
+	slices.SortStableFunc(todos, func(a, b session.Todo) int {
+		return statusOrder(a.Status) - statusOrder(b.Status)
+	})
+}
+
+func statusOrder(s session.TodoStatus) int {
+	switch s {
+	case session.TodoStatusCompleted:
+		return 0
+	case session.TodoStatusInProgress:
+		return 1
+	default:
+		return 2
+	}
+}
+
+func FormatTodosList(todos []session.Todo, inProgressIcon string, t *styles.Theme, width int) string {
+	if len(todos) == 0 {
+		return ""
+	}
+
+	sorted := make([]session.Todo, len(todos))
+	copy(sorted, todos)
+	sortTodos(sorted)
+
+	var lines []string
+	for _, todo := range sorted {
+		var prefix string
+		var textStyle lipgloss.Style
+
+		switch todo.Status {
+		case session.TodoStatusCompleted:
+			prefix = t.S().Base.Foreground(t.Green).Render(styles.TodoCompletedIcon) + " "
+			textStyle = t.S().Base.Foreground(t.FgBase)
+		case session.TodoStatusInProgress:
+			prefix = t.S().Base.Foreground(t.GreenDark).Render(inProgressIcon + " ")
+			textStyle = t.S().Base.Foreground(t.FgBase)
+		default:
+			prefix = t.S().Base.Foreground(t.FgMuted).Render(styles.TodoPendingIcon) + " "
+			textStyle = t.S().Base.Foreground(t.FgBase)
+		}
+
+		text := todo.Content
+		if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
+			text = todo.ActiveForm
+		}
+		line := prefix + textStyle.Render(text)
+		line = ansi.Truncate(line, width, "…")
+
+		lines = append(lines, line)
+	}
+
+	return strings.Join(lines, "\n")
+}
diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go
index 088a999e..c075a35a 100644
--- a/internal/tui/components/dialogs/models/keys.go
+++ b/internal/tui/components/dialogs/models/keys.go
@@ -15,7 +15,7 @@ type KeyMap struct {
 	isAPIKeyHelp  bool
 	isAPIKeyValid bool
 
-	isClaudeAuthChoiseHelp    bool
+	isClaudeAuthChoiceHelp    bool
 	isClaudeOAuthHelp         bool
 	isClaudeOAuthURLState     bool
 	isClaudeOAuthHelpComplete bool
@@ -74,7 +74,7 @@ func (k KeyMap) FullHelp() [][]key.Binding {
 
 // ShortHelp implements help.KeyMap.
 func (k KeyMap) ShortHelp() []key.Binding {
-	if k.isClaudeAuthChoiseHelp {
+	if k.isClaudeAuthChoiceHelp {
 		return []key.Binding{
 			key.NewBinding(
 				key.WithKeys("left", "right", "h", "l"),
diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go
index ff7243ca..6248f1f0 100644
--- a/internal/tui/components/dialogs/models/models.go
+++ b/internal/tui/components/dialogs/models/models.go
@@ -164,7 +164,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			}
 
 			askForApiKey := func() {
-				m.keyMap.isClaudeAuthChoiseHelp = false
+				m.keyMap.isClaudeAuthChoiceHelp = false
 				m.keyMap.isClaudeOAuthHelp = false
 				m.keyMap.isAPIKeyHelp = true
 				m.showClaudeAuthMethodChooser = false
@@ -183,7 +183,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 					m.selectedModelType = modelType
 					m.showClaudeAuthMethodChooser = false
 					m.showClaudeOAuth2 = true
-					m.keyMap.isClaudeAuthChoiseHelp = false
+					m.keyMap.isClaudeAuthChoiceHelp = false
 					m.keyMap.isClaudeOAuthHelp = true
 				}
 				return m, nil
@@ -252,7 +252,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			} else {
 				if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
 					m.showClaudeAuthMethodChooser = true
-					m.keyMap.isClaudeAuthChoiseHelp = true
+					m.keyMap.isClaudeAuthChoiceHelp = true
 					return m, nil
 				}
 				askForApiKey()
@@ -278,7 +278,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			if m.showClaudeAuthMethodChooser {
 				m.claudeAuthMethodChooser.SetDefaults()
 				m.showClaudeAuthMethodChooser = false
-				m.keyMap.isClaudeAuthChoiseHelp = false
+				m.keyMap.isClaudeAuthChoiceHelp = false
 				m.keyMap.isClaudeOAuthHelp = false
 				return m, nil
 			}
diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go
index 9b1810f8..d743d36f 100644
--- a/internal/tui/components/dialogs/permissions/permissions.go
+++ b/internal/tui/components/dialogs/permissions/permissions.go
@@ -389,7 +389,7 @@ func (p *permissionDialogCmp) renderHeader() string {
 	case tools.AgenticFetchToolName:
 		headerParts = append(headerParts,
 			baseStyle.Render(strings.Repeat(" ", p.width)),
-			t.S().Muted.Width(p.width).Bold(true).Render("URL"),
+			t.S().Muted.Width(p.width).Bold(true).Render("Web"),
 		)
 	case tools.ViewToolName:
 		params := p.permission.Params.(tools.ViewPermissionsParams)
@@ -605,7 +605,12 @@ 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)
+		var content string
+		if pr.URL != "" {
+			content = fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt)
+		} else {
+			content = fmt.Sprintf("Prompt: %s", pr.Prompt)
+		}
 		finalContent := baseStyle.
 			Padding(1, 2).
 			Width(p.contentViewPort.Width()).
diff --git a/internal/tui/components/image/load.go b/internal/tui/components/image/load.go
index 1a31bd3d..2ca5d4ba 100644
--- a/internal/tui/components/image/load.go
+++ b/internal/tui/components/image/load.go
@@ -3,7 +3,9 @@
 package image
 
 import (
+	"bytes"
 	"context"
+	"encoding/base64"
 	"image"
 	"image/png"
 	"io"
@@ -144,3 +146,24 @@ func svgToImage(width uint, height uint, r io.Reader) (string, error) {
 	}
 	return imageToString(width, height, img)
 }
+
+// ImageFromBase64 renders an image from base64-encoded data.
+func ImageFromBase64(width, height uint, data, mediaType string) (string, error) {
+	decoded, err := base64.StdEncoding.DecodeString(data)
+	if err != nil {
+		return "", err
+	}
+
+	r := bytes.NewReader(decoded)
+
+	if strings.Contains(mediaType, "svg") {
+		return svgToImage(width, height, r)
+	}
+
+	img, _, err := imageorient.Decode(r)
+	if err != nil {
+		return "", err
+	}
+
+	return imageToString(width, height, img)
+}
diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go
index f7ded77b..8956bfa6 100644
--- a/internal/tui/exp/list/filterable.go
+++ b/internal/tui/exp/list/filterable.go
@@ -245,7 +245,7 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
 
 	f.selectedItemIdx = -1
 	if query == "" || len(f.items) == 0 {
-		return f.list.SetItems(f.items)
+		return f.list.SetItems(f.visibleItems(f.items))
 	}
 
 	matches := fuzzy.FindFrom(query, f)
@@ -274,7 +274,7 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
 
 func (f *filterableList[T]) SetItems(items []T) tea.Cmd {
 	f.items = items
-	return f.list.SetItems(items)
+	return f.list.SetItems(f.visibleItems(items))
 }
 
 func (f *filterableList[T]) Cursor() *tea.Cursor {
@@ -317,3 +317,13 @@ func (f *filterableList[T]) String(i int) string {
 func (f *filterableList[T]) Len() int {
 	return len(f.items)
 }
+
+// visibleItems returns the subset of items that should be rendered based on
+// the configured resultsSize limit. The underlying source (f.items) remains
+// intact so filtering still searches the full set.
+func (f *filterableList[T]) visibleItems(items []T) []T {
+	if f.resultsSize > 0 && len(items) > f.resultsSize {
+		return items[:f.resultsSize]
+	}
+	return items
+}
diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go
index 63c8d806..653dcd7b 100644
--- a/internal/tui/exp/list/list.go
+++ b/internal/tui/exp/list/list.go
@@ -350,13 +350,12 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
 	scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
 	uv.NewStyledString(view).Draw(scr, area)
 
-	selArea := uv.Rectangle{
-		Min: uv.Pos(l.selectionStartCol, l.selectionStartLine),
-		Max: uv.Pos(l.selectionEndCol, l.selectionEndLine),
-	}
-	selArea = selArea.Canon()
-
+	selArea := l.selectionArea(false)
 	specialChars := getSpecialCharsMap()
+	selStyle := uv.Style{
+		Bg: t.TextSelection.GetBackground(),
+		Fg: t.TextSelection.GetForeground(),
+	}
 
 	isNonWhitespace := func(r rune) bool {
 		return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
@@ -371,9 +370,9 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
 	for y := range scr.Height() {
 		bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
 
-		if y >= selArea.Min.Y && y <= selArea.Max.Y {
+		if y >= selArea.Min.Y && y < selArea.Max.Y {
 			bounds.inSelection = true
-			if selArea.Min.Y == selArea.Max.Y {
+			if selArea.Min.Y == selArea.Max.Y-1 {
 				// Single line selection
 				bounds.startX = selArea.Min.X
 				bounds.endX = selArea.Max.X
@@ -381,7 +380,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
 				// First line of multi-line selection
 				bounds.startX = selArea.Min.X
 				bounds.endX = scr.Width()
-			} else if y == selArea.Max.Y {
+			} else if y == selArea.Max.Y-1 {
 				// Last line of multi-line selection
 				bounds.startX = 0
 				bounds.endX = selArea.Max.X
@@ -470,13 +469,9 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
 					continue
 				}
 
-				// Text selection styling, which is a Lip Gloss style. We must
-				// extract the values to use in a UV style, below.
-				ts := t.TextSelection
-
 				cell = cell.Clone()
-				cell.Style.Bg = ts.GetBackground()
-				cell.Style.Fg = ts.GetForeground()
+				cell.Style.Bg = selStyle.Bg
+				cell.Style.Fg = selStyle.Fg
 				scr.SetCell(x, y, cell)
 			}
 		}
@@ -864,6 +859,7 @@ func (l *list[T]) selectLastItem() {
 }
 
 func (l *list[T]) firstSelectableItemAbove(inx int) int {
+	unfocusableCount := 0
 	for i := inx - 1; i >= 0; i-- {
 		if i < 0 || i >= len(l.items) {
 			continue
@@ -873,14 +869,16 @@ func (l *list[T]) firstSelectableItemAbove(inx int) int {
 		if _, ok := any(item).(layout.Focusable); ok {
 			return i
 		}
+		unfocusableCount++
 	}
-	if inx == 0 && l.wrap {
+	if unfocusableCount == inx && l.wrap {
 		return l.firstSelectableItemAbove(len(l.items))
 	}
 	return ItemNotFound
 }
 
 func (l *list[T]) firstSelectableItemBelow(inx int) int {
+	unfocusableCount := 0
 	itemsLen := len(l.items)
 	for i := inx + 1; i < itemsLen; i++ {
 		if i < 0 || i >= len(l.items) {
@@ -891,8 +889,9 @@ func (l *list[T]) firstSelectableItemBelow(inx int) int {
 		if _, ok := any(item).(layout.Focusable); ok {
 			return i
 		}
+		unfocusableCount++
 	}
-	if inx == itemsLen-1 && l.wrap {
+	if unfocusableCount == itemsLen-inx-1 && l.wrap {
 		return l.firstSelectableItemBelow(-1)
 	}
 	return ItemNotFound
@@ -1352,6 +1351,14 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
 	}
 	// Pre-allocate with expected capacity
 	cmds := make([]tea.Cmd, 0, 2)
+	if newIndex > l.selectedItemIdx && l.selectedItemIdx > 0 && l.offset > 0 {
+		// this means there is a section above and not showing on the top, move to the top
+		newIndex = l.selectedItemIdx
+		cmd := l.GoToTop()
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
 	if newIndex == 1 {
 		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
 		if peakAboveIndex == ItemNotFound {
@@ -1383,12 +1390,16 @@ func (l *list[T]) SelectItemBelow() tea.Cmd {
 
 	newIndex := l.firstSelectableItemBelow(l.selectedItemIdx)
 	if newIndex == ItemNotFound {
-		// no item above
+		// no item below
 		return nil
 	}
 	if newIndex < 0 || newIndex >= len(l.items) {
 		return nil
 	}
+	if newIndex < l.selectedItemIdx {
+		// reset offset when wrap to the top to show the top section if it exists
+		l.offset = 0
+	}
 	l.prevSelectedItemIdx = l.selectedItemIdx
 	l.selectedItemIdx = newIndex
 	l.movingByItem = true
@@ -1465,8 +1476,13 @@ func (l *list[T]) reset(selectedItemID string) tea.Cmd {
 // SetSize implements List.
 func (l *list[T]) SetSize(width int, height int) tea.Cmd {
 	oldWidth := l.width
+	oldHeight := l.height
 	l.width = width
 	l.height = height
+	// Invalidate cache if height changed
+	if oldHeight != height {
+		l.cachedViewDirty = true
+	}
 	if oldWidth != width {
 		// Get current selected item ID before reset
 		selectedID := ""
@@ -1685,11 +1701,75 @@ func (l *list[T]) HasSelection() bool {
 	return l.hasSelection()
 }
 
+func (l *list[T]) selectionArea(absolute bool) uv.Rectangle {
+	var startY int
+	if absolute {
+		startY, _ = l.viewPosition()
+	}
+	selArea := uv.Rectangle{
+		Min: uv.Pos(l.selectionStartCol, l.selectionStartLine+startY),
+		Max: uv.Pos(l.selectionEndCol, l.selectionEndLine+startY),
+	}
+	selArea = selArea.Canon()
+	selArea.Max.Y++ // make max Y exclusive
+	return selArea
+}
+
 // GetSelectedText returns the currently selected text.
 func (l *list[T]) GetSelectedText(paddingLeft int) string {
 	if !l.hasSelection() {
 		return ""
 	}
 
-	return l.selectionView(l.View(), true)
+	selArea := l.selectionArea(true)
+	if selArea.Empty() {
+		return ""
+	}
+
+	selectionHeight := selArea.Dy()
+
+	tempBuf := uv.NewScreenBuffer(l.width, selectionHeight)
+	tempBufArea := tempBuf.Bounds()
+	renderedLines := l.getLines(selArea.Min.Y, selArea.Max.Y)
+	styled := uv.NewStyledString(renderedLines)
+	styled.Draw(tempBuf, tempBufArea)
+
+	// XXX: Left padding assumes the list component is rendered with absolute
+	// positioning. The chat component has a left margin of 1 and items in the
+	// list have a border of 1 plus a padding of 1. The paddingLeft parameter
+	// assumes this total left padding of 3 and we should fix that.
+	leftBorder := paddingLeft - 1
+
+	var b strings.Builder
+	for y := tempBufArea.Min.Y; y < tempBufArea.Max.Y; y++ {
+		var pending strings.Builder
+		for x := tempBufArea.Min.X + leftBorder; x < tempBufArea.Max.X; {
+			cell := tempBuf.CellAt(x, y)
+			if cell == nil || cell.IsZero() {
+				x++
+				continue
+			}
+			if y == 0 && x < selArea.Min.X {
+				x++
+				continue
+			}
+			if y == selectionHeight-1 && x > selArea.Max.X-1 {
+				break
+			}
+			if cell.Width == 1 && cell.Content == " " {
+				pending.WriteString(cell.Content)
+				x++
+				continue
+			}
+			b.WriteString(pending.String())
+			pending.Reset()
+			b.WriteString(cell.Content)
+			x += cell.Width
+		}
+		if y < tempBufArea.Max.Y-1 {
+			b.WriteByte('\n')
+		}
+	}
+
+	return b.String()
 }
diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go
index f09f9782..8ba323e1 100644
--- a/internal/tui/page/chat/chat.go
+++ b/internal/tui/page/chat/chat.go
@@ -57,6 +57,14 @@ const (
 	PanelTypeSplash PanelType = "splash"
 )
 
+// PillSection represents which pill section is focused when in pills panel.
+type PillSection int
+
+const (
+	PillSectionTodos PillSection = iota
+	PillSectionQueue
+)
+
 const (
 	CompactModeWidthBreakpoint  = 120 // Width at which the chat page switches to compact mode
 	CompactModeHeightBreakpoint = 30  // Height at which the chat page switches to compact mode
@@ -116,9 +124,18 @@ type chatPage struct {
 	splashFullScreen bool
 	isOnboarding     bool
 	isProjectInit    bool
+	promptQueue      int
+
+	// Pills state
+	pillsExpanded      bool
+	focusedPillSection PillSection
+
+	// Todo spinner
+	todoSpinner spinner.Model
 }
 
 func New(app *app.App) ChatPage {
+	t := styles.CurrentTheme()
 	return &chatPage{
 		app:         app,
 		keyMap:      DefaultKeyMap(),
@@ -128,6 +145,10 @@ func New(app *app.App) ChatPage {
 		editor:      editor.New(app),
 		splash:      splash.New(),
 		focusedPane: PanelTypeSplash,
+		todoSpinner: spinner.New(
+			spinner.WithSpinner(spinner.MiniDot),
+			spinner.WithStyle(t.S().Base.Foreground(t.GreenDark)),
+		),
 	}
 }
 
@@ -166,6 +187,13 @@ func (p *chatPage) Init() tea.Cmd {
 
 func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
+	if p.session.ID != "" && p.app.AgentCoordinator != nil {
+		queueSize := p.app.AgentCoordinator.QueuedPrompts(p.session.ID)
+		if queueSize != p.promptQueue {
+			p.promptQueue = queueSize
+			cmds = append(cmds, p.SetSize(p.width, p.height))
+		}
+	}
 	switch msg := msg.(type) {
 	case tea.KeyboardEnhancementsMsg:
 		p.keyboardEnhancements = msg
@@ -181,7 +209,7 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 		return p, nil
 	case tea.MouseClickMsg:
-		if p.isOnboarding {
+		if p.isOnboarding || p.isProjectInit {
 			return p, nil
 		}
 		if p.compact {
@@ -210,7 +238,7 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 		return p, nil
 	case tea.MouseReleaseMsg:
-		if p.isOnboarding {
+		if p.isOnboarding || p.isProjectInit {
 			return p, nil
 		}
 		if p.compact {
@@ -268,6 +296,19 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		p.editor = u.(editor.Editor)
 		return p, cmd
 	case pubsub.Event[session.Session]:
+		if msg.Payload.ID == p.session.ID {
+			prevHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
+			prevHasInProgress := p.hasInProgressTodo()
+			p.session = msg.Payload
+			newHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
+			newHasInProgress := p.hasInProgressTodo()
+			if prevHasIncompleteTodos != newHasIncompleteTodos {
+				cmds = append(cmds, p.SetSize(p.width, p.height))
+			}
+			if !prevHasInProgress && newHasInProgress {
+				cmds = append(cmds, p.todoSpinner.Tick)
+			}
+		}
 		u, cmd := p.header.Update(msg)
 		p.header = u.(header.Header)
 		cmds = append(cmds, cmd)
@@ -311,6 +352,17 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	case pubsub.Event[message.Message],
 		anim.StepMsg,
 		spinner.TickMsg:
+		// Update todo spinner if agent is busy and we have in-progress todos
+		agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
+		if _, ok := msg.(spinner.TickMsg); ok && p.hasInProgressTodo() && agentBusy {
+			var cmd tea.Cmd
+			p.todoSpinner, cmd = p.todoSpinner.Update(msg)
+			cmds = append(cmds, cmd)
+		}
+		// Start spinner when agent becomes busy and we have in-progress todos
+		if _, ok := msg.(pubsub.Event[message.Message]); ok && p.hasInProgressTodo() && agentBusy {
+			cmds = append(cmds, p.todoSpinner.Tick)
+		}
 		if p.focusedPane == PanelTypeSplash {
 			u, cmd := p.splash.Update(msg)
 			p.splash = u.(splash.Splash)
@@ -379,8 +431,17 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			}
 			return p, p.newSession()
 		case key.Matches(msg, p.keyMap.AddAttachment):
+			// Skip attachment handling during onboarding/splash screen
+			if p.focusedPane == PanelTypeSplash || p.isOnboarding {
+				u, cmd := p.splash.Update(msg)
+				p.splash = u.(splash.Splash)
+				return p, cmd
+			}
 			agentCfg := config.Get().Agents[config.AgentCoder]
 			model := config.Get().GetModelByType(agentCfg.Model)
+			if model == nil {
+				return p, util.ReportWarn("No model configured yet")
+			}
 			if model.SupportsImages {
 				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
 			} else {
@@ -392,8 +453,7 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				p.splash = u.(splash.Splash)
 				return p, cmd
 			}
-			p.changeFocus()
-			return p, nil
+			return p, p.changeFocus()
 		case key.Matches(msg, p.keyMap.Cancel):
 			if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
 				return p, p.cancel()
@@ -401,6 +461,18 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		case key.Matches(msg, p.keyMap.Details):
 			p.toggleDetails()
 			return p, nil
+		case key.Matches(msg, p.keyMap.TogglePills):
+			if p.session.ID != "" {
+				return p, p.togglePillsExpanded()
+			}
+		case key.Matches(msg, p.keyMap.PillLeft):
+			if p.session.ID != "" && p.pillsExpanded {
+				return p, p.switchPillSection(-1)
+			}
+		case key.Matches(msg, p.keyMap.PillRight):
+			if p.session.ID != "" && p.pillsExpanded {
+				return p, p.switchPillSection(1)
+			}
 		}
 
 		switch p.focusedPane {
@@ -474,19 +546,91 @@ func (p *chatPage) View() string {
 	} else {
 		messagesView := p.chat.View()
 		editorView := p.editor.View()
+
+		hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
+		hasQueue := p.promptQueue > 0
+		todosFocused := p.pillsExpanded && p.focusedPillSection == PillSectionTodos
+		queueFocused := p.pillsExpanded && p.focusedPillSection == PillSectionQueue
+
+		// Use spinner when agent is busy, otherwise show static icon
+		agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
+		inProgressIcon := t.S().Base.Foreground(t.GreenDark).Render(styles.CenterSpinnerIcon)
+		if agentBusy {
+			inProgressIcon = p.todoSpinner.View()
+		}
+
+		var pills []string
+		if hasIncompleteTodos {
+			pills = append(pills, todoPill(p.session.Todos, inProgressIcon, todosFocused, p.pillsExpanded, t))
+		}
+		if hasQueue {
+			pills = append(pills, queuePill(p.promptQueue, queueFocused, p.pillsExpanded, t))
+		}
+
+		var expandedList string
+		if p.pillsExpanded {
+			if todosFocused && hasIncompleteTodos {
+				expandedList = todoList(p.session.Todos, inProgressIcon, t, p.width-SideBarWidth)
+			} else if queueFocused && hasQueue {
+				queueItems := p.app.AgentCoordinator.QueuedPromptsList(p.session.ID)
+				expandedList = queueList(queueItems, t)
+			}
+		}
+
+		var pillsArea string
+		if len(pills) > 0 {
+			pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
+
+			// Add help hint for expanding/collapsing pills based on state.
+			var helpDesc string
+			if p.pillsExpanded {
+				helpDesc = "close"
+			} else {
+				helpDesc = "open"
+			}
+			// Style to match help section: keys in FgMuted, description in FgSubtle
+			helpKey := t.S().Base.Foreground(t.FgMuted).Render("ctrl+space")
+			helpText := t.S().Base.Foreground(t.FgSubtle).Render(helpDesc)
+			helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
+			pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
+
+			if expandedList != "" {
+				pillsArea = lipgloss.JoinVertical(
+					lipgloss.Left,
+					pillsRow,
+					expandedList,
+				)
+			} else {
+				pillsArea = pillsRow
+			}
+
+			style := t.S().Base.MarginTop(1).PaddingLeft(3)
+			pillsArea = style.Render(pillsArea)
+		}
+
 		if p.compact {
 			headerView := p.header.View()
-			chatView = lipgloss.JoinVertical(
-				lipgloss.Left,
-				headerView,
-				messagesView,
-				editorView,
-			)
+			views := []string{headerView, messagesView}
+			if pillsArea != "" {
+				views = append(views, pillsArea)
+			}
+			views = append(views, editorView)
+			chatView = lipgloss.JoinVertical(lipgloss.Left, views...)
 		} else {
 			sidebarView := p.sidebar.View()
+			var messagesColumn string
+			if pillsArea != "" {
+				messagesColumn = lipgloss.JoinVertical(
+					lipgloss.Left,
+					messagesView,
+					pillsArea,
+				)
+			} else {
+				messagesColumn = messagesView
+			}
 			messages := lipgloss.JoinHorizontal(
 				lipgloss.Left,
-				messagesView,
+				messagesColumn,
 				sidebarView,
 			)
 			chatView = lipgloss.JoinVertical(
@@ -516,9 +660,7 @@ func (p *chatPage) View() string {
 		)
 		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
 	}
-	canvas := lipgloss.NewCanvas(
-		layers...,
-	)
+	canvas := lipgloss.NewCompositor(layers...)
 	return canvas.Render()
 }
 
@@ -650,14 +792,30 @@ func (p *chatPage) SetSize(width, height int) tea.Cmd {
 			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 		}
 	} else {
+		hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
+		hasQueue := p.promptQueue > 0
+		hasPills := hasIncompleteTodos || hasQueue
+
+		pillsAreaHeight := 0
+		if hasPills {
+			pillsAreaHeight = pillHeightWithBorder + 1 // +1 for padding top
+			if p.pillsExpanded {
+				if p.focusedPillSection == PillSectionTodos && hasIncompleteTodos {
+					pillsAreaHeight += len(p.session.Todos)
+				} else if p.focusedPillSection == PillSectionQueue && hasQueue {
+					pillsAreaHeight += p.promptQueue
+				}
+			}
+		}
+
 		if p.compact {
-			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
+			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight-pillsAreaHeight))
 			p.detailsWidth = width - DetailsPositioning
 			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
 			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
 		} else {
-			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
+			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight-pillsAreaHeight))
 			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
 		}
@@ -682,37 +840,77 @@ func (p *chatPage) newSession() tea.Cmd {
 	)
 }
 
-func (p *chatPage) setSession(session session.Session) tea.Cmd {
-	if p.session.ID == session.ID {
+func (p *chatPage) setSession(sess session.Session) tea.Cmd {
+	if p.session.ID == sess.ID {
 		return nil
 	}
 
 	var cmds []tea.Cmd
-	p.session = session
+	p.session = sess
+
+	if p.hasInProgressTodo() {
+		cmds = append(cmds, p.todoSpinner.Tick)
+	}
 
 	cmds = append(cmds, p.SetSize(p.width, p.height))
-	cmds = append(cmds, p.chat.SetSession(session))
-	cmds = append(cmds, p.sidebar.SetSession(session))
-	cmds = append(cmds, p.header.SetSession(session))
-	cmds = append(cmds, p.editor.SetSession(session))
+	cmds = append(cmds, p.chat.SetSession(sess))
+	cmds = append(cmds, p.sidebar.SetSession(sess))
+	cmds = append(cmds, p.header.SetSession(sess))
+	cmds = append(cmds, p.editor.SetSession(sess))
 
 	return tea.Sequence(cmds...)
 }
 
-func (p *chatPage) changeFocus() {
+func (p *chatPage) changeFocus() tea.Cmd {
 	if p.session.ID == "" {
-		return
+		return nil
 	}
+
 	switch p.focusedPane {
-	case PanelTypeChat:
-		p.focusedPane = PanelTypeEditor
-		p.editor.Focus()
-		p.chat.Blur()
 	case PanelTypeEditor:
 		p.focusedPane = PanelTypeChat
 		p.chat.Focus()
 		p.editor.Blur()
+	case PanelTypeChat:
+		p.focusedPane = PanelTypeEditor
+		p.editor.Focus()
+		p.chat.Blur()
 	}
+	return nil
+}
+
+func (p *chatPage) togglePillsExpanded() tea.Cmd {
+	hasPills := hasIncompleteTodos(p.session.Todos) || p.promptQueue > 0
+	if !hasPills {
+		return nil
+	}
+	p.pillsExpanded = !p.pillsExpanded
+	if p.pillsExpanded {
+		if hasIncompleteTodos(p.session.Todos) {
+			p.focusedPillSection = PillSectionTodos
+		} else {
+			p.focusedPillSection = PillSectionQueue
+		}
+	}
+	return p.SetSize(p.width, p.height)
+}
+
+func (p *chatPage) switchPillSection(dir int) tea.Cmd {
+	if !p.pillsExpanded {
+		return nil
+	}
+	hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
+	hasQueue := p.promptQueue > 0
+
+	if dir < 0 && p.focusedPillSection == PillSectionQueue && hasIncompleteTodos {
+		p.focusedPillSection = PillSectionTodos
+		return p.SetSize(p.width, p.height)
+	}
+	if dir > 0 && p.focusedPillSection == PillSectionTodos && hasQueue {
+		p.focusedPillSection = PillSectionQueue
+		return p.SetSize(p.width, p.height)
+	}
+	return nil
 }
 
 func (p *chatPage) cancel() tea.Cmd {
@@ -998,23 +1196,42 @@ func (p *chatPage) Help() help.KeyMap {
 		globalBindings := []key.Binding{}
 		// we are in a session
 		if p.session.ID != "" {
-			tabKey := key.NewBinding(
-				key.WithKeys("tab"),
-				key.WithHelp("tab", "focus chat"),
-			)
-			if p.focusedPane == PanelTypeChat {
+			var tabKey key.Binding
+			switch p.focusedPane {
+			case PanelTypeEditor:
+				tabKey = key.NewBinding(
+					key.WithKeys("tab"),
+					key.WithHelp("tab", "focus chat"),
+				)
+			case PanelTypeChat:
 				tabKey = key.NewBinding(
 					key.WithKeys("tab"),
 					key.WithHelp("tab", "focus editor"),
 				)
+			default:
+				tabKey = key.NewBinding(
+					key.WithKeys("tab"),
+					key.WithHelp("tab", "focus chat"),
+				)
 			}
 			shortList = append(shortList, tabKey)
 			globalBindings = append(globalBindings, tabKey)
+
+			// Show left/right to switch sections when expanded and both exist
+			hasTodos := hasIncompleteTodos(p.session.Todos)
+			hasQueue := p.promptQueue > 0
+			if p.pillsExpanded && hasTodos && hasQueue {
+				shortList = append(shortList, p.keyMap.PillLeft)
+				globalBindings = append(globalBindings, p.keyMap.PillLeft)
+			}
 		}
 		commandsBinding := key.NewBinding(
 			key.WithKeys("ctrl+p"),
 			key.WithHelp("ctrl+p", "commands"),
 		)
+		if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() {
+			commandsBinding.SetHelp("/ or ctrl+p", "commands")
+		}
 		modelsBinding := key.NewBinding(
 			key.WithKeys("ctrl+m", "ctrl+l"),
 			key.WithHelp("ctrl+l", "models"),
@@ -1197,3 +1414,12 @@ func (p *chatPage) isMouseOverChat(x, y int) bool {
 	// Check if mouse coordinates are within chat bounds
 	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
 }
+
+func (p *chatPage) hasInProgressTodo() bool {
+	for _, todo := range p.session.Todos {
+		if todo.Status == session.TodoStatusInProgress {
+			return true
+		}
+	}
+	return false
+}
diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go
index b175b4bf..f22ec2bb 100644
--- a/internal/tui/page/chat/keys.go
+++ b/internal/tui/page/chat/keys.go
@@ -10,6 +10,9 @@ type KeyMap struct {
 	Cancel        key.Binding
 	Tab           key.Binding
 	Details       key.Binding
+	TogglePills   key.Binding
+	PillLeft      key.Binding
+	PillRight     key.Binding
 }
 
 func DefaultKeyMap() KeyMap {
@@ -34,5 +37,17 @@ func DefaultKeyMap() KeyMap {
 			key.WithKeys("ctrl+d"),
 			key.WithHelp("ctrl+d", "toggle details"),
 		),
+		TogglePills: key.NewBinding(
+			key.WithKeys("ctrl+space"),
+			key.WithHelp("ctrl+space", "toggle tasks"),
+		),
+		PillLeft: key.NewBinding(
+			key.WithKeys("left"),
+			key.WithHelp("←/→", "switch section"),
+		),
+		PillRight: key.NewBinding(
+			key.WithKeys("right"),
+			key.WithHelp("←/→", "switch section"),
+		),
 	}
 }
diff --git a/internal/tui/page/chat/pills.go b/internal/tui/page/chat/pills.go
new file mode 100644
index 00000000..40a36362
--- /dev/null
+++ b/internal/tui/page/chat/pills.go
@@ -0,0 +1,125 @@
+package chat
+
+import (
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/components/chat/todos"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+)
+
+func hasIncompleteTodos(todos []session.Todo) bool {
+	for _, todo := range todos {
+		if todo.Status != session.TodoStatusCompleted {
+			return true
+		}
+	}
+	return false
+}
+
+const (
+	pillHeightWithBorder  = 3
+	maxTaskDisplayLength  = 40
+	maxQueueDisplayLength = 60
+)
+
+func queuePill(queue int, focused, pillsPanelFocused bool, t *styles.Theme) string {
+	if queue <= 0 {
+		return ""
+	}
+	triangles := styles.ForegroundGrad("▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Accent)
+	if queue < 10 {
+		triangles = triangles[:queue]
+	}
+
+	content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue)
+
+	style := t.S().Base.PaddingLeft(1).PaddingRight(1)
+	if !pillsPanelFocused || focused {
+		style = style.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.BgOverlay)
+	} else {
+		style = style.BorderStyle(lipgloss.HiddenBorder())
+	}
+	return style.Render(content)
+}
+
+func todoPill(todos []session.Todo, spinnerView string, focused, pillsPanelFocused bool, t *styles.Theme) string {
+	if !hasIncompleteTodos(todos) {
+		return ""
+	}
+
+	completed := 0
+	var currentTodo *session.Todo
+	for i := range todos {
+		switch todos[i].Status {
+		case session.TodoStatusCompleted:
+			completed++
+		case session.TodoStatusInProgress:
+			if currentTodo == nil {
+				currentTodo = &todos[i]
+			}
+		}
+	}
+
+	total := len(todos)
+
+	label := "To-Do"
+	progress := t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("%d/%d", completed, total))
+
+	var content string
+	if pillsPanelFocused {
+		content = fmt.Sprintf("%s %s", label, progress)
+	} else if currentTodo != nil {
+		taskText := currentTodo.Content
+		if currentTodo.ActiveForm != "" {
+			taskText = currentTodo.ActiveForm
+		}
+		if len(taskText) > maxTaskDisplayLength {
+			taskText = taskText[:maxTaskDisplayLength-1] + "…"
+		}
+		task := t.S().Base.Foreground(t.FgSubtle).Render(taskText)
+		content = fmt.Sprintf("%s %s %s  %s", spinnerView, label, progress, task)
+	} else {
+		content = fmt.Sprintf("%s %s", label, progress)
+	}
+
+	style := t.S().Base.PaddingLeft(1).PaddingRight(1)
+	if !pillsPanelFocused || focused {
+		style = style.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.BgOverlay)
+	} else {
+		style = style.BorderStyle(lipgloss.HiddenBorder())
+	}
+	return style.Render(content)
+}
+
+func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Theme, width int) string {
+	return todos.FormatTodosList(sessionTodos, spinnerView, t, width)
+}
+
+func queueList(queueItems []string, t *styles.Theme) string {
+	if len(queueItems) == 0 {
+		return ""
+	}
+
+	var lines []string
+	for _, item := range queueItems {
+		text := item
+		if len(text) > maxQueueDisplayLength {
+			text = text[:maxQueueDisplayLength-1] + "…"
+		}
+		prefix := t.S().Base.Foreground(t.FgMuted).Render("  •") + " "
+		lines = append(lines, prefix+t.S().Base.Foreground(t.FgMuted).Render(text))
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+func sectionLine(availableWidth int, t *styles.Theme) string {
+	if availableWidth <= 0 {
+		return ""
+	}
+	line := strings.Repeat("─", availableWidth)
+	return t.S().Base.Foreground(t.Border).Render(line)
+}
diff --git a/internal/tui/styles/chroma.go b/internal/tui/styles/chroma.go
index b6521bea..bd656336 100644
--- a/internal/tui/styles/chroma.go
+++ b/internal/tui/styles/chroma.go
@@ -1,8 +1,8 @@
 package styles
 
 import (
+	"charm.land/glamour/v2/ansi"
 	"github.com/alecthomas/chroma/v2"
-	"github.com/charmbracelet/glamour/v2/ansi"
 )
 
 func chromaStyle(style ansi.StylePrimitive) string {
diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go
index d9d1ab06..dfb3cf0c 100644
--- a/internal/tui/styles/icons.go
+++ b/internal/tui/styles/icons.go
@@ -1,15 +1,17 @@
 package styles
 
 const (
-	CheckIcon    string = "✓"
-	ErrorIcon    string = "×"
-	WarningIcon  string = "⚠"
-	InfoIcon     string = "ⓘ"
-	HintIcon     string = "∵"
-	SpinnerIcon  string = "..."
-	LoadingIcon  string = "⟳"
-	DocumentIcon string = "🖼"
-	ModelIcon    string = "◇"
+	CheckIcon         string = "✓"
+	ErrorIcon         string = "×"
+	WarningIcon       string = "⚠"
+	InfoIcon          string = "ⓘ"
+	HintIcon          string = "∵"
+	SpinnerIcon       string = "..."
+	ArrowRightIcon    string = "→"
+	CenterSpinnerIcon string = "⋯"
+	LoadingIcon       string = "⟳"
+	DocumentIcon      string = "🖼"
+	ModelIcon         string = "◇"
 
 	// Tool call icons
 	ToolPending string = "●"
@@ -18,6 +20,10 @@ const (
 
 	BorderThin  string = "│"
 	BorderThick string = "▌"
+
+	// Todo icons
+	TodoCompletedIcon string = "✓"
+	TodoPendingIcon   string = "•"
 )
 
 var SelectionIgnoreIcons = []string{
diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go
index 549ac87f..fd857703 100644
--- a/internal/tui/styles/markdown.go
+++ b/internal/tui/styles/markdown.go
@@ -4,8 +4,8 @@ import (
 	"fmt"
 	"image/color"
 
-	"github.com/charmbracelet/glamour/v2"
-	"github.com/charmbracelet/glamour/v2/ansi"
+	"charm.land/glamour/v2"
+	"charm.land/glamour/v2/ansi"
 )
 
 // lipglossColorToHex converts a color.Color to hex string
diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go
index 9605dd2d..f87ffd9d 100644
--- a/internal/tui/styles/theme.go
+++ b/internal/tui/styles/theme.go
@@ -10,9 +10,9 @@ import (
 	"charm.land/bubbles/v2/textarea"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/glamour/v2/ansi"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
-	"github.com/charmbracelet/glamour/v2/ansi"
 	"github.com/charmbracelet/x/exp/charmtone"
 	"github.com/lucasb-eyer/go-colorful"
 	"github.com/rivo/uniseg"
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 45f0ae5e..e91fae55 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -593,22 +593,15 @@ func (a *appModel) View() tea.View {
 	view.MouseMode = tea.MouseModeCellMotion
 	view.BackgroundColor = t.BgBase
 	if a.wWidth < 25 || a.wHeight < 15 {
-		view.SetContent(
-			lipgloss.NewCanvas(
-				lipgloss.NewLayer(
-					t.S().Base.Width(a.wWidth).Height(a.wHeight).
-						Align(lipgloss.Center, lipgloss.Center).
-						Render(
-							t.S().Base.
-								Padding(1, 4).
-								Foreground(t.White).
-								BorderStyle(lipgloss.RoundedBorder()).
-								BorderForeground(t.Primary).
-								Render("Window too small!"),
-						),
-				),
-			).Render(),
-		)
+		view.Content = t.S().Base.Width(a.wWidth).Height(a.wHeight).
+			Align(lipgloss.Center, lipgloss.Center).
+			Render(t.S().Base.
+				Padding(1, 4).
+				Foreground(t.White).
+				BorderStyle(lipgloss.RoundedBorder()).
+				BorderForeground(t.Primary).
+				Render("Window too small!"),
+			)
 		return view
 	}
 
@@ -659,11 +652,8 @@ func (a *appModel) View() tea.View {
 		)
 	}
 
-	canvas := lipgloss.NewCanvas(
-		layers...,
-	)
-
-	view.Content = canvas.Render()
+	comp := lipgloss.NewCompositor(layers...)
+	view.Content = comp.Render()
 	view.Cursor = cursor
 
 	if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
diff --git a/schema.json b/schema.json
index 47740b9c..97420085 100644
--- a/schema.json
+++ b/schema.json
@@ -229,6 +229,16 @@
           "description": "Whether this MCP server is disabled",
           "default": false
         },
+        "disabled_tools": {
+          "items": {
+            "type": "string",
+            "examples": [
+              "get-library-doc"
+            ]
+          },
+          "type": "array",
+          "description": "List of tools from this MCP server to disable"
+        },
         "timeout": {
           "type": "integer",
           "description": "Timeout in seconds for MCP server connections",
@@ -386,10 +396,14 @@
         },
         "disabled_tools": {
           "items": {
-            "type": "string"
+            "type": "string",
+            "examples": [
+              "bash",
+              "sourcegraph"
+            ]
           },
           "type": "array",
-          "description": "Tools to disable"
+          "description": "List of built-in tools to disable and hide from the agent"
         },
         "disable_provider_auto_update": {
           "type": "boolean",
@@ -418,10 +432,7 @@
         }
       },
       "additionalProperties": false,
-      "type": "object",
-      "required": [
-        "disabled_tools"
-      ]
+      "type": "object"
     },
     "Permissions": {
       "properties": {



Test patch:
diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
index 0a513b91..ca5bb10d 100644
--- a/internal/agent/agent_test.go
+++ b/internal/agent/agent_test.go
@@ -453,6 +453,10 @@ func TestCoderAgent(t *testing.T) {
 				require.Contains(t, string(content), "Hello, Crush!", "Expected file to contain 'Hello, Crush!'")
 			})
 			t.Run("sourcegraph tool", func(t *testing.T) {
+				if runtime.GOOS == "darwin" {
+					t.Skip("skipping flacky test on macos for now")
+				}
+
 				agent, env := setupAgent(t, pair)
 
 				session, err := env.sessions.Create(t.Context(), "New Session")
diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go
index 58264e26..0464af1d 100644
--- a/internal/agent/common_test.go
+++ b/internal/agent/common_test.go
@@ -149,7 +149,7 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro
 			DefaultMaxTokens: 10000,
 		},
 	}
-	agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, true, env.sessions, env.messages, tools})
+	agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, false, true, env.sessions, env.messages, tools})
 	return agent
 }
 
diff --git a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml b/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml
index db313590..886c7ade 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml 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
index ccbc87b3..ba19fab1 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/download_tool.yaml 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
index b515f396..2cb0516e 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/fetch_tool.yaml 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
index aac486c4..bf992d4c 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/glob_tool.yaml 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
index 2408263d..92f63b5c 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/grep_tool.yaml 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
index c92b0b41..f3ec88d4 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/ls_tool.yaml 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
index dba21f13..bb1c3fca 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/multiedit_tool.yaml 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
index 83c13442..5064079b 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/parallel_tool_calls.yaml 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
index 1fe3e99c..778bc751 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/read_a_file.yaml 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
index f10a2e49..7a8c8fbc 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/simple_test.yaml 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
index dac279f2..7f05e69b 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/sourcegraph_tool.yaml 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
index 8af7279d..3365286e 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/update_a_file.yaml 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
index 28574b2f..41525df4 100644
Binary files a/internal/agent/testdata/TestCoderAgent/anthropic-sonnet/write_tool.yaml 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
index c2a7f721..7fb77213 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/bash_tool.yaml 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
index 5ed21101..97bc2c93 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/download_tool.yaml 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
index 7a6e1e91..ffd1d72e 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/fetch_tool.yaml 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
index 0aba2d6d..fd4a3a74 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/glob_tool.yaml 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
index 70cee752..116188e8 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/grep_tool.yaml 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
index 05885c45..0069c019 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/ls_tool.yaml 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
index 09b1b1c9..bc2fd664 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/multiedit_tool.yaml 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
index a60052a5..6653e9e3 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/parallel_tool_calls.yaml 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
index f950dacc..3f757a1b 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/read_a_file.yaml 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
index 33b3ff7c..bc2d7d0f 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/simple_test.yaml 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
index d25d3da7..9473a72d 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/sourcegraph_tool.yaml 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
index a78827b5..b31ab8fb 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/update_a_file.yaml 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
index e7b26f26..adc9a404 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openai-gpt-5/write_tool.yaml 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
index e20f5315..4128dd17 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/bash_tool.yaml 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
index 6c602993..fad7231e 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/download_tool.yaml 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
index e4adf187..cb22a491 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/fetch_tool.yaml 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
index 92da2f2a..9d3e30a2 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/glob_tool.yaml 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
index 805a0c47..c7ceb5aa 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/grep_tool.yaml 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
index 3bbabeaa..1d0e80a7 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/ls_tool.yaml 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
index f8eb39b4..01fa9e48 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/multiedit_tool.yaml 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
index 22790951..cbe6379f 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/parallel_tool_calls.yaml 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
index 35354428..ad83b7d9 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/read_a_file.yaml 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
index f0ab2e91..f1f71e81 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/simple_test.yaml 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
index e970be98..0642c4d8 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/sourcegraph_tool.yaml 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
index 2cb35c1e..72ec1e95 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/update_a_file.yaml 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
index 366e518b..ac1d2424 100644
Binary files a/internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/write_tool.yaml 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
index 24bcc9c3..cd1c433a 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/bash_tool.yaml 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
index 8ac845f8..6dae0e6d 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/download_tool.yaml 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
index 5df70626..f13518a0 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/fetch_tool.yaml 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
index 014d52ea..24ca96d1 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/glob_tool.yaml 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
index 5febe50f..595d99b4 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/grep_tool.yaml 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
index 95c84dd7..325c58ec 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/ls_tool.yaml 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
index c0835366..39370769 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/multiedit_tool.yaml 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
index 3fbc7185..d96fad12 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/parallel_tool_calls.yaml 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
index ccbd7343..225018ea 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/read_a_file.yaml 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
index ed1990c0..17ba2c8f 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/simple_test.yaml 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
index 198d185e..2335dbd1 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/sourcegraph_tool.yaml 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
index c56069c5..1565efcf 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/update_a_file.yaml 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
index ea9d1c8a..1ffde38f 100644
Binary files a/internal/agent/testdata/TestCoderAgent/zai-glm4.6/write_tool.yaml and b/internal/agent/testdata/TestCoderAgent/zai-glm4.6/write_tool.yaml differ
diff --git a/internal/cmd/projects_test.go b/internal/cmd/projects_test.go
new file mode 100644
index 00000000..50585ea2
--- /dev/null
+++ b/internal/cmd/projects_test.go
@@ -0,0 +1,56 @@
+package cmd
+
+import (
+	"bytes"
+	"encoding/json"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/projects"
+	"github.com/stretchr/testify/require"
+)
+
+func TestProjectsEmpty(t *testing.T) {
+	// Use a temp directory for projects.json
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	var b bytes.Buffer
+	projectsCmd.SetOut(&b)
+	projectsCmd.SetErr(&b)
+	projectsCmd.SetIn(bytes.NewReader(nil))
+	err := projectsCmd.RunE(projectsCmd, nil)
+	require.NoError(t, err)
+	require.Equal(t, "No projects tracked yet.\n", b.String())
+}
+
+func TestProjectsJSON(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Register a project
+	err := projects.Register("/test/project", "/test/project/.crush")
+	require.NoError(t, err)
+
+	var b bytes.Buffer
+	projectsCmd.SetOut(&b)
+	projectsCmd.SetErr(&b)
+	projectsCmd.SetIn(bytes.NewReader(nil))
+
+	// Set the --json flag
+	projectsCmd.Flags().Set("json", "true")
+	defer projectsCmd.Flags().Set("json", "false")
+
+	err = projectsCmd.RunE(projectsCmd, nil)
+	require.NoError(t, err)
+
+	// Parse the JSON output
+	var result struct {
+		Projects []projects.Project `json:"projects"`
+	}
+	err = json.Unmarshal(b.Bytes(), &result)
+	require.NoError(t, err)
+
+	require.Len(t, result.Projects, 1)
+	require.Equal(t, "/test/project", result.Projects[0].Path)
+	require.Equal(t, "/test/project/.crush", result.Projects[0].DataDir)
+}
diff --git a/internal/config/load_test.go b/internal/config/load_test.go
index af50daad..4819a195 100644
--- a/internal/config/load_test.go
+++ b/internal/config/load_test.go
@@ -487,7 +487,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
 
-	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
@@ -510,7 +510,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 	cfg.SetupAgents()
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
-	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "todos", "write"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
diff --git a/internal/config/provider_empty_test.go b/internal/config/provider_empty_test.go
index f3691c32..5e889f82 100644
--- a/internal/config/provider_empty_test.go
+++ b/internal/config/provider_empty_test.go
@@ -1,8 +1,7 @@
 package config
 
 import (
-	"encoding/json"
-	"os"
+	"context"
 	"testing"
 
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -11,37 +10,22 @@ import (
 
 type emptyProviderClient struct{}
 
-func (m *emptyProviderClient) GetProviders() ([]catwalk.Provider, error) {
+func (m *emptyProviderClient) GetProviders(context.Context, string) ([]catwalk.Provider, error) {
 	return []catwalk.Provider{}, nil
 }
 
+// TestProvider_loadProvidersEmptyResult tests that loadProviders returns an
+// error when the client returns an empty list. This ensures we don't cache
+// empty provider lists.
 func TestProvider_loadProvidersEmptyResult(t *testing.T) {
 	client := &emptyProviderClient{}
 	tmpPath := t.TempDir() + "/providers.json"
 
-	providers, err := loadProviders(false, client, tmpPath)
-	require.Contains(t, err.Error(), "Crush was unable to fetch an updated list of providers")
+	providers, err := loadProviders(client, "", tmpPath)
+	require.Contains(t, err.Error(), "empty providers list from catwalk")
 	require.Empty(t, providers)
 	require.Len(t, providers, 0)
 
 	// Check that no cache file was created for empty results
 	require.NoFileExists(t, tmpPath, "Cache file should not exist for empty results")
 }
-
-func TestProvider_loadProvidersEmptyCache(t *testing.T) {
-	client := &mockProviderClient{shouldFail: false}
-	tmpPath := t.TempDir() + "/providers.json"
-
-	// Create an empty cache file
-	emptyProviders := []catwalk.Provider{}
-	data, err := json.Marshal(emptyProviders)
-	require.NoError(t, err)
-	require.NoError(t, os.WriteFile(tmpPath, data, 0o644))
-
-	// Should refresh and get real providers instead of using empty cache
-	providers, err := loadProviders(false, client, tmpPath)
-	require.NoError(t, err)
-	require.NotNil(t, providers)
-	require.Len(t, providers, 1)
-	require.Equal(t, "Mock", providers[0].Name)
-}
diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go
index 1262b60e..f101dd8d 100644
--- a/internal/config/provider_test.go
+++ b/internal/config/provider_test.go
@@ -1,9 +1,11 @@
 package config
 
 import (
+	"context"
 	"encoding/json"
 	"errors"
 	"os"
+	"sync"
 	"testing"
 
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -11,10 +13,14 @@ import (
 )
 
 type mockProviderClient struct {
-	shouldFail bool
+	shouldFail      bool
+	shouldReturnErr error
 }
 
-func (m *mockProviderClient) GetProviders() ([]catwalk.Provider, error) {
+func (m *mockProviderClient) GetProviders(context.Context, string) ([]catwalk.Provider, error) {
+	if m.shouldReturnErr != nil {
+		return nil, m.shouldReturnErr
+	}
 	if m.shouldFail {
 		return nil, errors.New("failed to load providers")
 	}
@@ -25,10 +31,16 @@ func (m *mockProviderClient) GetProviders() ([]catwalk.Provider, error) {
 	}, nil
 }
 
+func resetProviderState() {
+	providerOnce = sync.Once{}
+	providerList = nil
+	providerErr = nil
+}
+
 func TestProvider_loadProvidersNoIssues(t *testing.T) {
 	client := &mockProviderClient{shouldFail: false}
 	tmpPath := t.TempDir() + "/providers.json"
-	providers, err := loadProviders(false, client, tmpPath)
+	providers, err := loadProviders(client, "", tmpPath)
 	require.NoError(t, err)
 	require.NotNil(t, providers)
 	require.Len(t, providers, 1)
@@ -39,35 +51,94 @@ func TestProvider_loadProvidersNoIssues(t *testing.T) {
 	require.False(t, fileInfo.IsDir(), "Expected a file, not a directory")
 }
 
-func TestProvider_loadProvidersWithIssues(t *testing.T) {
-	client := &mockProviderClient{shouldFail: true}
-	tmpPath := t.TempDir() + "/providers.json"
-	// store providers to a temporary file
-	oldProviders := []catwalk.Provider{
-		{
-			Name: "OldProvider",
+func TestProvider_DisableAutoUpdate(t *testing.T) {
+	t.Setenv("XDG_DATA_HOME", t.TempDir())
+
+	resetProviderState()
+	defer resetProviderState()
+
+	cfg := &Config{
+		Options: &Options{
+			DisableProviderAutoUpdate: true,
 		},
 	}
-	data, err := json.Marshal(oldProviders)
-	if err != nil {
-		t.Fatalf("Failed to marshal old providers: %v", err)
-	}
 
-	err = os.WriteFile(tmpPath, data, 0o644)
-	if err != nil {
-		t.Fatalf("Failed to write old providers to file: %v", err)
+	providers, err := Providers(cfg)
+	require.NoError(t, err)
+	require.NotNil(t, providers)
+	require.Greater(t, len(providers), 5, "Expected embedded providers")
+}
+
+func TestProvider_WithValidCache(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	resetProviderState()
+	defer resetProviderState()
+
+	cachePath := tmpDir + "/crush/providers.json"
+	require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
+	cachedProviders := []catwalk.Provider{
+		{Name: "Cached"},
 	}
-	providers, err := loadProviders(true, client, tmpPath)
+	data, err := json.Marshal(cachedProviders)
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(cachePath, data, 0o644))
+
+	mockClient := &mockProviderClient{shouldFail: false}
+
+	providers, err := loadProviders(mockClient, "", cachePath)
 	require.NoError(t, err)
 	require.NotNil(t, providers)
 	require.Len(t, providers, 1)
-	require.Equal(t, "OldProvider", providers[0].Name, "Expected to keep old provider when loading fails")
+	require.Equal(t, "Mock", providers[0].Name, "Expected fresh provider from fetch")
+}
+
+func TestProvider_NotModifiedUsesCached(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	resetProviderState()
+	defer resetProviderState()
+
+	cachePath := tmpDir + "/crush/providers.json"
+	require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
+	cachedProviders := []catwalk.Provider{
+		{Name: "Cached"},
+	}
+	data, err := json.Marshal(cachedProviders)
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(cachePath, data, 0o644))
+
+	mockClient := &mockProviderClient{shouldReturnErr: catwalk.ErrNotModified}
+	providers, err := loadProviders(mockClient, "", cachePath)
+	require.ErrorIs(t, err, catwalk.ErrNotModified)
+	require.Nil(t, providers)
+}
+
+func TestProvider_EmptyCacheDefaultsToEmbedded(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	resetProviderState()
+	defer resetProviderState()
+
+	cachePath := tmpDir + "/crush/providers.json"
+	require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
+	emptyProviders := []catwalk.Provider{}
+	data, err := json.Marshal(emptyProviders)
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(cachePath, data, 0o644))
+
+	cached, _, err := loadProvidersFromCache(cachePath)
+	require.NoError(t, err)
+	require.Empty(t, cached, "Expected empty cache")
 }
 
 func TestProvider_loadProvidersWithIssuesAndNoCache(t *testing.T) {
 	client := &mockProviderClient{shouldFail: true}
 	tmpPath := t.TempDir() + "/providers.json"
-	providers, err := loadProviders(false, client, tmpPath)
+	providers, err := loadProviders(client, "", tmpPath)
 	require.Error(t, err)
 	require.Nil(t, providers, "Expected nil providers when loading fails and no cache exists")
 }
diff --git a/internal/env/env_test.go b/internal/env/env_test.go
index 6bd323e0..a86b8f41 100644
--- a/internal/env/env_test.go
+++ b/internal/env/env_test.go
@@ -8,7 +8,7 @@ import (
 )
 
 func TestOsEnv_Get(t *testing.T) {
-	env := New()
+	env := &osEnv{}
 
 	// Test getting an existing environment variable
 	t.Setenv("TEST_VAR", "test_value")
@@ -22,7 +22,7 @@ func TestOsEnv_Get(t *testing.T) {
 }
 
 func TestOsEnv_Env(t *testing.T) {
-	env := New()
+	env := &osEnv{}
 
 	envVars := env.Env()
 
diff --git a/internal/projects/projects_test.go b/internal/projects/projects_test.go
new file mode 100644
index 00000000..2919410a
--- /dev/null
+++ b/internal/projects/projects_test.go
@@ -0,0 +1,180 @@
+package projects
+
+import (
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+func TestRegisterAndList(t *testing.T) {
+	// Create a temporary directory for the test
+	tmpDir := t.TempDir()
+
+	// Override the projects file path for testing
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Test registering a project
+	err := Register("/home/user/project1", "/home/user/project1/.crush")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	// List projects
+	projects, err := List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 1 {
+		t.Fatalf("Expected 1 project, got %d", len(projects))
+	}
+
+	if projects[0].Path != "/home/user/project1" {
+		t.Errorf("Expected path /home/user/project1, got %s", projects[0].Path)
+	}
+
+	if projects[0].DataDir != "/home/user/project1/.crush" {
+		t.Errorf("Expected data_dir /home/user/project1/.crush, got %s", projects[0].DataDir)
+	}
+
+	// Register another project
+	err = Register("/home/user/project2", "/home/user/project2/.crush")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, err = List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 2 {
+		t.Fatalf("Expected 2 projects, got %d", len(projects))
+	}
+
+	// Most recent should be first
+	if projects[0].Path != "/home/user/project2" {
+		t.Errorf("Expected most recent project first, got %s", projects[0].Path)
+	}
+}
+
+func TestRegisterUpdatesExisting(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Register a project
+	err := Register("/home/user/project1", "/home/user/project1/.crush")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, _ := List()
+	firstAccess := projects[0].LastAccessed
+
+	// Wait a bit and re-register
+	time.Sleep(10 * time.Millisecond)
+
+	err = Register("/home/user/project1", "/home/user/project1/.crush-new")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, _ = List()
+
+	if len(projects) != 1 {
+		t.Fatalf("Expected 1 project after update, got %d", len(projects))
+	}
+
+	if projects[0].DataDir != "/home/user/project1/.crush-new" {
+		t.Errorf("Expected updated data_dir, got %s", projects[0].DataDir)
+	}
+
+	if !projects[0].LastAccessed.After(firstAccess) {
+		t.Error("Expected LastAccessed to be updated")
+	}
+}
+
+func TestLoadEmptyFile(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// List before any projects exist
+	projects, err := List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 0 {
+		t.Errorf("Expected 0 projects, got %d", len(projects))
+	}
+}
+
+func TestProjectsFilePath(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	expected := filepath.Join(tmpDir, "crush", "projects.json")
+	actual := projectsFilePath()
+
+	if actual != expected {
+		t.Errorf("Expected %s, got %s", expected, actual)
+	}
+}
+
+func TestRegisterWithParentDataDir(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Register a project where .crush is in a parent directory.
+	// e.g., working in /home/user/monorepo/packages/app but .crush is at /home/user/monorepo/.crush
+	err := Register("/home/user/monorepo/packages/app", "/home/user/monorepo/.crush")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, err := List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 1 {
+		t.Fatalf("Expected 1 project, got %d", len(projects))
+	}
+
+	if projects[0].Path != "/home/user/monorepo/packages/app" {
+		t.Errorf("Expected path /home/user/monorepo/packages/app, got %s", projects[0].Path)
+	}
+
+	if projects[0].DataDir != "/home/user/monorepo/.crush" {
+		t.Errorf("Expected data_dir /home/user/monorepo/.crush, got %s", projects[0].DataDir)
+	}
+}
+
+func TestRegisterWithExternalDataDir(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Register a project where .crush is in a completely different location.
+	// e.g., project at /home/user/project but data stored at /var/data/crush/myproject
+	err := Register("/home/user/project", "/var/data/crush/myproject")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, err := List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 1 {
+		t.Fatalf("Expected 1 project, got %d", len(projects))
+	}
+
+	if projects[0].Path != "/home/user/project" {
+		t.Errorf("Expected path /home/user/project, got %s", projects[0].Path)
+	}
+
+	if projects[0].DataDir != "/var/data/crush/myproject" {
+		t.Errorf("Expected data_dir /var/data/crush/myproject, got %s", projects[0].DataDir)
+	}
+}
diff --git a/internal/shell/background_test.go b/internal/shell/background_test.go
index 8d0ba200..5149861d 100644
--- a/internal/shell/background_test.go
+++ b/internal/shell/background_test.go
@@ -2,6 +2,7 @@ package shell
 
 import (
 	"context"
+	"runtime"
 	"strings"
 	"testing"
 	"time"
@@ -171,6 +172,10 @@ func TestBackgroundShell_WithBlockFuncs(t *testing.T) {
 }
 
 func TestBackgroundShellManager_List(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("skipping flacky test on windows")
+	}
+
 	t.Parallel()
 
 	ctx := context.Background()