Merge remote-tracking branch 'origin/main' into feat/docker-mcp-integration

Kujtim Hoxha created

Change summary

.github/cla-signatures.json                                    |  40 +
.github/workflows/snapshot.yml                                 |   2 
.goreleaser.yml                                                |   1 
AGENTS.md                                                      |   2 
README.md                                                      |   2 
Taskfile.yaml                                                  |   8 
go.mod                                                         |   2 
go.sum                                                         |   4 
internal/agent/agent.go                                        |  46 +
internal/agent/agentic_fetch_tool.go                           |   2 
internal/agent/common_test.go                                  |  14 
internal/agent/coordinator.go                                  |  26 
internal/agent/hyper/provider.go                               |   7 
internal/agent/tools/edit.go                                   |  53 
internal/agent/tools/mcp/init.go                               |  12 
internal/agent/tools/mcp/prompts.go                            |   2 
internal/agent/tools/mcp/tools.go                              |   2 
internal/agent/tools/multiedit.go                              |  35 
internal/agent/tools/multiedit_test.go                         |  14 
internal/agent/tools/view.go                                   |  20 
internal/agent/tools/write.go                                  |  25 
internal/app/app.go                                            |  37 
internal/app/lsp.go                                            |   2 
internal/app/provider_test.go                                  |   2 
internal/cmd/models.go                                         |   2 
internal/cmd/root.go                                           |  11 
internal/cmd/run.go                                            |  12 
internal/cmd/stats/index.css                                   |  13 
internal/config/catwalk.go                                     |   4 
internal/config/catwalk_test.go                                |   2 
internal/config/config.go                                      |   9 
internal/config/copilot.go                                     |   2 
internal/config/docker_mcp_test.go                             |   2 
internal/config/hyper.go                                       |   2 
internal/config/hyper_test.go                                  |   2 
internal/config/load.go                                        |   2 
internal/config/load_test.go                                   |   2 
internal/config/provider.go                                    |   4 
internal/config/provider_empty_test.go                         |   2 
internal/config/provider_test.go                               |   2 
internal/db/db.go                                              |  20 
internal/db/migrations/20260127000000_add_read_files_table.sql |  20 
internal/db/models.go                                          |   6 
internal/db/querier.go                                         |   2 
internal/db/read_files.sql.go                                  |  57 +
internal/db/sql/read_files.sql                                 |  15 
internal/event/event.go                                        |  10 
internal/event/event_test.go                                   |  74 ++
internal/filetracker/filetracker.go                            |  70 --
internal/filetracker/service.go                                |  70 ++
internal/filetracker/service_test.go                           | 116 ++++
internal/fsext/ls.go                                           |  12 
internal/home/home.go                                          |   2 
internal/lsp/client.go                                         |   8 
internal/message/content.go                                    |   2 
internal/session/session.go                                    |   2 
internal/stringext/string.go                                   |  12 
internal/tui/components/chat/editor/editor.go                  |  27 
internal/tui/components/chat/messages/messages.go              |   2 
internal/tui/components/chat/sidebar/sidebar.go                |   7 
internal/tui/components/chat/splash/splash.go                  |   2 
internal/tui/components/dialogs/commands/commands.go           |   4 
internal/tui/components/dialogs/models/list.go                 |   2 
internal/tui/components/dialogs/models/list_recent_test.go     |   2 
internal/tui/components/dialogs/models/models.go               |   2 
internal/tui/page/chat/chat.go                                 |   3 
internal/ui/chat/messages.go                                   |   2 
internal/ui/chat/tools.go                                      |  32 
internal/ui/dialog/actions.go                                  |   2 
internal/ui/dialog/api_key_input.go                            |   2 
internal/ui/dialog/commands.go                                 |  11 
internal/ui/dialog/commands_item.go                            |   4 
internal/ui/dialog/models.go                                   |   2 
internal/ui/dialog/models_item.go                              |   6 
internal/ui/dialog/oauth.go                                    |   2 
internal/ui/dialog/oauth_copilot.go                            |   2 
internal/ui/dialog/oauth_hyper.go                              |   2 
internal/ui/dialog/reasoning.go                                |   4 
internal/ui/dialog/sessions_item.go                            |  16 
internal/ui/image/image.go                                     |   2 
internal/ui/list/highlight.go                                  |   3 
internal/ui/list/list.go                                       |   4 
internal/ui/model/history.go                                   |   2 
internal/ui/model/keys.go                                      |   5 
internal/ui/model/sidebar.go                                   |   6 
internal/ui/model/ui.go                                        | 101 ++
internal/ui/styles/styles.go                                   |  32 
schema.json                                                    |   8 
scripts/check_log_capitalization.sh                            |   5 
89 files changed, 874 insertions(+), 363 deletions(-)

Detailed changes

.github/cla-signatures.json ๐Ÿ”—

@@ -1127,6 +1127,46 @@
       "created_at": "2026-01-24T22:42:46Z",
       "repoId": 987670088,
       "pullRequestNo": 1978
+    },
+    {
+      "name": "oug-t",
+      "id": 252025851,
+      "comment_id": 3811704206,
+      "created_at": "2026-01-28T14:42:29Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2022
+    },
+    {
+      "name": "liannnix",
+      "id": 779758,
+      "comment_id": 3815867093,
+      "created_at": "2026-01-29T07:05:12Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2043
+    },
+    {
+      "name": "bittoby",
+      "id": 218712309,
+      "comment_id": 3824931235,
+      "created_at": "2026-01-30T17:52:15Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2065
+    },
+    {
+      "name": "ijt",
+      "id": 15530,
+      "comment_id": 3832667774,
+      "created_at": "2026-02-02T03:06:23Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2080
+    },
+    {
+      "name": "khalilgharbaoui",
+      "id": 8024057,
+      "comment_id": 3832796060,
+      "created_at": "2026-02-02T04:04:04Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2081
     }
   ]
 }

.github/workflows/snapshot.yml ๐Ÿ”—

@@ -27,7 +27,7 @@ jobs:
           go-version-file: go.mod
       - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
         with:
-          version: "~> v2"
+          version: "nightly"
           distribution: goreleaser-pro
           args: build --snapshot --clean
         env:

.goreleaser.yml ๐Ÿ”—

@@ -268,6 +268,7 @@ nix:
       name: "Charm"
       email: "charmcli@users.noreply.github.com"
     license: fsl11Mit
+    formatter: nixfmt
     skip_upload: "{{ with .Prerelease }}true{{ end }}"
     extra_install: |-
       installManPage ./manpages/crush.1.gz

AGENTS.md ๐Ÿ”—

@@ -26,6 +26,8 @@
   need of a temporary directory. This directory does not need to be removed.
 - **JSON tags**: Use snake_case for JSON field names
 - **File permissions**: Use octal notation (0o755, 0o644) for file permissions
+- **Log messages**: Log messages must start with a capital letter (e.g., "Failed to save session" not "failed to save session")
+  - This is enforced by `task lint:log` which runs as part of `task lint`
 - **Comments**: End comments in periods unless comments are at the end of the line.
 
 ## Testing with Mock Providers

README.md ๐Ÿ”—

@@ -7,7 +7,7 @@
 </p>
 
 <p align="center">Your new coding bestie, now available in your favourite terminal.<br />Your tools, your code, and your workflows, wired into your LLM of choice.</p>
-<p align="center">ไฝ ็š„ๆ–ฐ็ผ–็จ‹ไผ™ไผด๏ผŒ็Žฐๅœจๅฐฑๅœจไฝ ๆœ€็ˆฑ็š„็ปˆ็ซฏไธญใ€‚<br />ไฝ ็š„ๅทฅๅ…ทใ€ไปฃ็ ๅ’Œๅทฅไฝœๆต๏ผŒ้ƒฝไธŽๆ‚จ้€‰ๆ‹ฉ็š„ LLM ๆจกๅž‹็ดงๅฏ†็›ธ่ฟžใ€‚</p>
+<p align="center">็ปˆ็ซฏ้‡Œ็š„็ผ–็จ‹ๆ–ฐๆญๆกฃ๏ผŒ<br />ๆ— ็ผๆŽฅๅ…ฅไฝ ็š„ๅทฅๅ…ทใ€ไปฃ็ ไธŽๅทฅไฝœๆต๏ผŒๅ…จ้ขๅ…ผๅฎนไธปๆต LLM ๆจกๅž‹ใ€‚</p>
 
 <p align="center"><img width="800" alt="Crush Demo" src="https://github.com/user-attachments/assets/58280caf-851b-470a-b6f7-d5c4ea8a1968" /></p>
 

Taskfile.yaml ๐Ÿ”—

@@ -23,10 +23,16 @@ tasks:
   lint:
     desc: Run base linters
     cmds:
+      - task: lint:log
       - golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m
     env:
       GOEXPERIMENT: null
 
+  lint:log:
+    desc: Check that log messages start with capital letters
+    cmds:
+      - ./scripts/check_log_capitalization.sh
+
   lint:fix:
     desc: Run base linters and fix issues
     cmds:
@@ -147,5 +153,5 @@ tasks:
     desc: Update Fantasy and Catwalk
     cmds:
       - go get charm.land/fantasy
-      - go get github.com/charmbracelet/catwalk
+      - go get charm.land/catwalk
       - go mod tidy

go.mod ๐Ÿ”—

@@ -5,6 +5,7 @@ go 1.25.5
 require (
 	charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66
 	charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e
+	charm.land/catwalk v0.16.0
 	charm.land/fantasy v0.6.1
 	charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b
 	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971
@@ -19,7 +20,6 @@ require (
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.10.0
 	github.com/charlievieth/fastwalk v1.0.14
-	github.com/charmbracelet/catwalk v0.15.0
 	github.com/charmbracelet/colorprofile v0.4.1
 	github.com/charmbracelet/fang v0.4.4
 	github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560

go.sum ๐Ÿ”—

@@ -2,6 +2,8 @@ charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv
 charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ=
 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8=
+charm.land/catwalk v0.16.0 h1:NP6lPz086OAsFdyYTRE6x1CyAosX6MpqdY303ntwsX0=
+charm.land/catwalk v0.16.0/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64=
 charm.land/fantasy v0.6.1 h1:v3pavSHpZ5xTw98TpNYoj6DRq4ksCBWwJiZeiG/mVIc=
 charm.land/fantasy v0.6.1/go.mod h1:Ifj41bNnIXJ1aF6sLKcS9y3MzWbDnObmcHrCaaHfpZ0=
 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0=
@@ -96,8 +98,6 @@ 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.15.0 h1:5oWJdvchTPfF7855A0n40+XbZQz4+vouZ/NhQ661JKI=
-github.com/charmbracelet/catwalk v0.15.0/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=

internal/agent/agent.go ๐Ÿ”—

@@ -22,6 +22,7 @@ import (
 	"sync"
 	"time"
 
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/fantasy"
 	"charm.land/fantasy/providers/anthropic"
 	"charm.land/fantasy/providers/bedrock"
@@ -29,9 +30,9 @@ import (
 	"charm.land/fantasy/providers/openai"
 	"charm.land/fantasy/providers/openrouter"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/message"
@@ -167,6 +168,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 	largeModel := a.largeModel.Get()
 	systemPrompt := a.systemPrompt.Get()
 	promptPrefix := a.systemPromptPrefix.Get()
+	var instructions strings.Builder
+
+	for _, server := range mcp.GetStates() {
+		if server.State != mcp.StateConnected {
+			continue
+		}
+		if s := server.Client.InitializeResult().Instructions; s != "" {
+			instructions.WriteString(s)
+			instructions.WriteString("\n\n")
+		}
+	}
+
+	if s := instructions.String(); s != "" {
+		systemPrompt += "\n\n<mcp-instructions>\n" + s + "\n</mcp-instructions>"
+	}
 
 	if len(agentTools) > 0 {
 		// Add Anthropic caching to the last tool.
@@ -789,22 +805,22 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 	resp, err := agent.Stream(ctx, streamCall)
 	if err == nil {
 		// We successfully generated a title with the small model.
-		slog.Info("generated title with small model")
+		slog.Debug("Generated title with small model")
 	} else {
 		// It didn't work. Let's try with the big model.
-		slog.Error("error generating title with small model; trying big model", "err", err)
+		slog.Error("Error generating title with small model; trying big model", "err", err)
 		model = largeModel
 		agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
 		resp, err = agent.Stream(ctx, streamCall)
 		if err == nil {
-			slog.Info("generated title with large model")
+			slog.Debug("Generated title with large model")
 		} else {
 			// Welp, the large model didn't work either. Use the default
 			// session name and return.
-			slog.Error("error generating title with large model", "err", err)
+			slog.Error("Error generating title with large model", "err", err)
 			saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
 			if saveErr != nil {
-				slog.Error("failed to save session title and usage", "error", saveErr)
+				slog.Error("Failed to save session title and usage", "error", saveErr)
 			}
 			return
 		}
@@ -813,10 +829,10 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 	if resp == nil {
 		// Actually, we didn't get a response so we can't. Use the default
 		// session name and return.
-		slog.Error("response is nil; can't generate title")
+		slog.Error("Response is nil; can't generate title")
 		saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
 		if saveErr != nil {
-			slog.Error("failed to save session title and usage", "error", saveErr)
+			slog.Error("Failed to save session title and usage", "error", saveErr)
 		}
 		return
 	}
@@ -830,7 +846,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 
 	title = strings.TrimSpace(title)
 	if title == "" {
-		slog.Warn("empty title; using fallback")
+		slog.Debug("Empty title; using fallback")
 		title = defaultSessionName
 	}
 
@@ -865,7 +881,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 	// concurrent session updates.
 	saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
 	if saveErr != nil {
-		slog.Error("failed to save session title and usage", "error", saveErr)
+		slog.Error("Failed to save session title and usage", "error", saveErr)
 		return
 	}
 }
@@ -908,25 +924,25 @@ func (a *sessionAgent) Cancel(sessionID string) {
 	// fully completes (including error handling that may access the DB).
 	// The defer in processRequest will clean up the entry.
 	if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil {
-		slog.Info("Request cancellation initiated", "session_id", sessionID)
+		slog.Debug("Request cancellation initiated", "session_id", sessionID)
 		cancel()
 	}
 
 	// Also check for summarize requests.
 	if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil {
-		slog.Info("Summarize cancellation initiated", "session_id", sessionID)
+		slog.Debug("Summarize cancellation initiated", "session_id", sessionID)
 		cancel()
 	}
 
 	if a.QueuedPrompts(sessionID) > 0 {
-		slog.Info("Clearing queued prompts", "session_id", sessionID)
+		slog.Debug("Clearing queued prompts", "session_id", sessionID)
 		a.messageQueue.Del(sessionID)
 	}
 }
 
 func (a *sessionAgent) ClearQueue(sessionID string) {
 	if a.QueuedPrompts(sessionID) > 0 {
-		slog.Info("Clearing queued prompts", "session_id", sessionID)
+		slog.Debug("Clearing queued prompts", "session_id", sessionID)
 		a.messageQueue.Del(sessionID)
 	}
 }
@@ -1086,7 +1102,7 @@ func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Mes
 			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)
+					slog.Warn("Failed to decode media data", "error", err)
 					textParts = append(textParts, part)
 					continue
 				}

internal/agent/agentic_fetch_tool.go ๐Ÿ”—

@@ -168,7 +168,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 				tools.NewGlobTool(tmpDir),
 				tools.NewGrepTool(tmpDir),
 				tools.NewSourcegraphTool(client),
-				tools.NewViewTool(c.lspClients, c.permissions, tmpDir),
+				tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, tmpDir),
 			}
 
 			agent := NewSessionAgent(SessionAgentOptions{

internal/agent/common_test.go ๐Ÿ”—

@@ -8,18 +8,19 @@ import (
 	"testing"
 	"time"
 
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/fantasy"
 	"charm.land/fantasy/providers/anthropic"
 	"charm.land/fantasy/providers/openai"
 	"charm.land/fantasy/providers/openaicompat"
 	"charm.land/fantasy/providers/openrouter"
 	"charm.land/x/vcr"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/prompt"
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/message"
@@ -37,6 +38,7 @@ type fakeEnv struct {
 	messages    message.Service
 	permissions permission.Service
 	history     history.Service
+	filetracker *filetracker.Service
 	lspClients  *csync.Map[string, *lsp.Client]
 }
 
@@ -117,6 +119,7 @@ func testEnv(t *testing.T) fakeEnv {
 
 	permissions := permission.NewPermissionService(workingDir, true, []string{})
 	history := history.NewService(q, conn)
+	filetrackerService := filetracker.NewService(q)
 	lspClients := csync.NewMap[string, *lsp.Client]()
 
 	t.Cleanup(func() {
@@ -130,6 +133,7 @@ func testEnv(t *testing.T) fakeEnv {
 		messages,
 		permissions,
 		history,
+		&filetrackerService,
 		lspClients,
 	}
 }
@@ -200,15 +204,15 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
 	allTools := []fantasy.AgentTool{
 		tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
 		tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
-		tools.NewEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
-		tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
+		tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
+		tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
 		tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
 		tools.NewGlobTool(env.workingDir),
 		tools.NewGrepTool(env.workingDir),
 		tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
 		tools.NewSourcegraphTool(r.GetDefaultClient()),
-		tools.NewViewTool(env.lspClients, env.permissions, env.workingDir),
-		tools.NewWriteTool(env.lspClients, env.permissions, env.history, env.workingDir),
+		tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir),
+		tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
 	}
 
 	return testSessionAgent(env, large, small, systemPrompt, allTools...), nil

internal/agent/coordinator.go ๐Ÿ”—

@@ -15,13 +15,14 @@ import (
 	"slices"
 	"strings"
 
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/fantasy"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/agent/prompt"
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/lsp"
@@ -65,6 +66,7 @@ type coordinator struct {
 	messages    message.Service
 	permissions permission.Service
 	history     history.Service
+	filetracker filetracker.Service
 	lspClients  *csync.Map[string, *lsp.Client]
 
 	currentAgent SessionAgent
@@ -80,6 +82,7 @@ func NewCoordinator(
 	messages message.Service,
 	permissions permission.Service,
 	history history.Service,
+	filetracker filetracker.Service,
 	lspClients *csync.Map[string, *lsp.Client],
 ) (Coordinator, error) {
 	c := &coordinator{
@@ -88,6 +91,7 @@ func NewCoordinator(
 		messages:    messages,
 		permissions: permissions,
 		history:     history,
+		filetracker: filetracker,
 		lspClients:  lspClients,
 		agents:      make(map[string]SessionAgent),
 	}
@@ -148,7 +152,7 @@ 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("Token needs to be refreshed", "provider", providerCfg.ID)
+		slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
 		if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
 			return nil, err
 		}
@@ -173,18 +177,18 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 	if c.isUnauthorized(originalErr) {
 		switch {
 		case providerCfg.OAuthToken != nil:
-			slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
+			slog.Debug("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)
+			slog.Debug("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)
+			slog.Debug("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)
+			slog.Debug("Retrying request with refreshed API key", "provider", providerCfg.ID)
 			return run()
 		}
 	}
@@ -394,16 +398,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		tools.NewJobOutputTool(),
 		tools.NewJobKillTool(),
 		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
-		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
-		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
+		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
 		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
 		tools.NewGlobTool(c.cfg.WorkingDir()),
 		tools.NewGrepTool(c.cfg.WorkingDir()),
 		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
 		tools.NewSourcegraphTool(nil),
 		tools.NewTodosTool(c.sessions),
-		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
-		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+		tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
+		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
 	)
 
 	if len(c.cfg.LSP) > 0 {
@@ -425,7 +429,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		}
 		if len(agent.AllowedMCP) == 0 {
 			// No MCPs allowed
-			slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
+			slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
 			break
 		}
 

internal/agent/hyper/provider.go ๐Ÿ”—

@@ -21,9 +21,9 @@ import (
 	"sync"
 	"time"
 
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/fantasy"
 	"charm.land/fantasy/object"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/event"
 )
 
@@ -49,7 +49,10 @@ var Enabled = sync.OnceValue(func() bool {
 var Embedded = sync.OnceValue(func() catwalk.Provider {
 	var provider catwalk.Provider
 	if err := json.Unmarshal(embedded, &provider); err != nil {
-		slog.Error("could not use embedded provider data", "err", err)
+		slog.Error("Could not use embedded provider data", "err", err)
+	}
+	if e := os.Getenv("HYPER_URL"); e != "" {
+		provider.APIEndpoint = e + "/api/v1/fantasy"
 	}
 	return provider
 })

internal/agent/tools/edit.go ๐Ÿ”—

@@ -56,10 +56,17 @@ type editContext struct {
 	ctx         context.Context
 	permissions permission.Service
 	files       history.Service
+	filetracker filetracker.Service
 	workingDir  string
 }
 
-func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+func NewEditTool(
+	lspClients *csync.Map[string, *lsp.Client],
+	permissions permission.Service,
+	files history.Service,
+	filetracker filetracker.Service,
+	workingDir string,
+) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		EditToolName,
 		string(editDescription),
@@ -73,7 +80,7 @@ func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
 			var response fantasy.ToolResponse
 			var err error
 
-			editCtx := editContext{ctx, permissions, files, workingDir}
+			editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
 
 			if params.OldString == "" {
 				response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
@@ -168,8 +175,7 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	filetracker.RecordWrite(filePath)
-	filetracker.RecordRead(filePath)
+	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
 
 	return fantasy.WithResponseMetadata(
 		fantasy.NewTextResponse("File created: "+filePath),
@@ -195,12 +201,17 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
 	}
 
-	if filetracker.LastReadTime(filePath).IsZero() {
+	sessionID := GetSessionFromContext(edit.ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
+	}
+
+	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
+	if lastRead.IsZero() {
 		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
 	}
 
-	modTime := fileInfo.ModTime()
-	lastRead := filetracker.LastReadTime(filePath)
+	modTime := fileInfo.ModTime().Truncate(time.Second)
 	if modTime.After(lastRead) {
 		return fantasy.NewTextErrorResponse(
 			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -236,12 +247,6 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 		newContent = oldContent[:index] + oldContent[index+len(oldString):]
 	}
 
-	sessionID := GetSessionFromContext(edit.ctx)
-
-	if sessionID == "" {
-		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
-	}
-
 	_, additions, removals := diff.GenerateDiff(
 		oldContent,
 		newContent,
@@ -301,8 +306,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	filetracker.RecordWrite(filePath)
-	filetracker.RecordRead(filePath)
+	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
 
 	return fantasy.WithResponseMetadata(
 		fantasy.NewTextResponse("Content deleted from file: "+filePath),
@@ -328,12 +332,17 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
 	}
 
-	if filetracker.LastReadTime(filePath).IsZero() {
+	sessionID := GetSessionFromContext(edit.ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
+	}
+
+	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
+	if lastRead.IsZero() {
 		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
 	}
 
-	modTime := fileInfo.ModTime()
-	lastRead := filetracker.LastReadTime(filePath)
+	modTime := fileInfo.ModTime().Truncate(time.Second)
 	if modTime.After(lastRead) {
 		return fantasy.NewTextErrorResponse(
 			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -369,11 +378,6 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 	if oldContent == newContent {
 		return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
 	}
-	sessionID := GetSessionFromContext(edit.ctx)
-
-	if sessionID == "" {
-		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
-	}
 	_, additions, removals := diff.GenerateDiff(
 		oldContent,
 		newContent,
@@ -433,8 +437,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	filetracker.RecordWrite(filePath)
-	filetracker.RecordRead(filePath)
+	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
 
 	return fantasy.WithResponseMetadata(
 		fantasy.NewTextResponse("Content replaced in file: "+filePath),

internal/agent/tools/mcp/init.go ๐Ÿ”—

@@ -140,7 +140,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 	for name, m := range cfg.MCP {
 		if m.Disabled {
 			updateState(name, StateDisabled, nil, nil, Counts{})
-			slog.Debug("skipping disabled mcp", "name", name)
+			slog.Debug("Skipping disabled MCP", "name", name)
 			continue
 		}
 
@@ -160,7 +160,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 						err = fmt.Errorf("panic: %v", v)
 					}
 					updateState(name, StateError, err, nil, Counts{})
-					slog.Error("panic in mcp client initialization", "error", err, "name", name)
+					slog.Error("Panic in MCP client initialization", "error", err, "name", name)
 				}
 			}()
 
@@ -213,7 +213,7 @@ func initClient(ctx context.Context, name string, m config.MCPConfig, resolver c
 
 	tools, err := getTools(ctx, session)
 	if err != nil {
-		slog.Error("error listing tools", "error", err)
+		slog.Error("Error listing tools", "error", err)
 		updateState(name, StateError, err, nil, Counts{})
 		session.Close()
 		return err
@@ -221,7 +221,7 @@ func initClient(ctx context.Context, name string, m config.MCPConfig, resolver c
 
 	prompts, err := getPrompts(ctx, session)
 	if err != nil {
-		slog.Error("error listing prompts", "error", err)
+		slog.Error("Error listing prompts", "error", err)
 		updateState(name, StateError, err, nil, Counts{})
 		session.Close()
 		return err
@@ -327,7 +327,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 	transport, err := createTransport(mcpCtx, m, resolver)
 	if err != nil {
 		updateState(name, StateError, err, nil, Counts{})
-		slog.Error("error creating mcp client", "error", err, "name", name)
+		slog.Error("Error creating MCP client", "error", err, "name", name)
 		cancel()
 		cancelTimer.Stop()
 		return nil, err
@@ -369,7 +369,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 	}
 
 	cancelTimer.Stop()
-	slog.Info("MCP client initialized", "name", name)
+	slog.Debug("MCP client initialized", "name", name)
 	return session, nil
 }
 

internal/agent/tools/mcp/prompts.go ๐Ÿ”—

@@ -49,7 +49,7 @@ func GetPromptMessages(ctx context.Context, clientName, promptName string, args
 func RefreshPrompts(ctx context.Context, name string) {
 	session, ok := sessions.Get(name)
 	if !ok {
-		slog.Warn("refresh prompts: no session", "name", name)
+		slog.Warn("Refresh prompts: no session", "name", name)
 		return
 	}
 

internal/agent/tools/mcp/tools.go ๐Ÿ”—

@@ -113,7 +113,7 @@ func RunTool(ctx context.Context, name, toolName string, input string) (ToolResu
 func RefreshTools(ctx context.Context, name string) {
 	session, ok := sessions.Get(name)
 	if !ok {
-		slog.Warn("refresh tools: no session", "name", name)
+		slog.Warn("Refresh tools: no session", "name", name)
 		return
 	}
 

internal/agent/tools/multiedit.go ๐Ÿ”—

@@ -58,7 +58,13 @@ const MultiEditToolName = "multiedit"
 //go:embed multiedit.md
 var multieditDescription []byte
 
-func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+func NewMultiEditTool(
+	lspClients *csync.Map[string, *lsp.Client],
+	permissions permission.Service,
+	files history.Service,
+	filetracker filetracker.Service,
+	workingDir string,
+) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		MultiEditToolName,
 		string(multieditDescription),
@@ -81,7 +87,7 @@ func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions pe
 			var response fantasy.ToolResponse
 			var err error
 
-			editCtx := editContext{ctx, permissions, files, workingDir}
+			editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
 			// Handle file creation case (first edit has empty old_string)
 			if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
 				response, err = processMultiEditWithCreation(editCtx, params, call)
@@ -210,8 +216,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	filetracker.RecordWrite(params.FilePath)
-	filetracker.RecordRead(params.FilePath)
+	edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
 
 	var message string
 	if len(failedEdits) > 0 {
@@ -247,14 +252,19 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
 	}
 
+	sessionID := GetSessionFromContext(edit.ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
+	}
+
 	// Check if file was read before editing
-	if filetracker.LastReadTime(params.FilePath).IsZero() {
+	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath)
+	if lastRead.IsZero() {
 		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
 	}
 
-	// Check if file was modified since last read
-	modTime := fileInfo.ModTime()
-	lastRead := filetracker.LastReadTime(params.FilePath)
+	// Check if file was modified since last read.
+	modTime := fileInfo.ModTime().Truncate(time.Second)
 	if modTime.After(lastRead) {
 		return fantasy.NewTextErrorResponse(
 			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -301,12 +311,6 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 		return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
 	}
 
-	// Get session and message IDs
-	sessionID := GetSessionFromContext(edit.ctx)
-	if sessionID == "" {
-		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
-	}
-
 	// Generate diff and check permissions
 	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
 
@@ -369,8 +373,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	filetracker.RecordWrite(params.FilePath)
-	filetracker.RecordRead(params.FilePath)
+	edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
 
 	var message string
 	if len(failedEdits) > 0 {

internal/agent/tools/multiedit_test.go ๐Ÿ”—

@@ -6,10 +6,7 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/history"
-	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/stretchr/testify/require"
@@ -111,17 +108,6 @@ func TestMultiEditSequentialApplication(t *testing.T) {
 	err := os.WriteFile(testFile, []byte(content), 0o644)
 	require.NoError(t, err)
 
-	// Mock components.
-	lspClients := csync.NewMap[string, *lsp.Client]()
-	permissions := &mockPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
-	files := &mockHistoryService{Broker: pubsub.NewBroker[history.File]()}
-
-	// Create multiedit tool.
-	_ = NewMultiEditTool(lspClients, permissions, files, tmpDir)
-
-	// Simulate reading the file first.
-	filetracker.RecordRead(testFile)
-
 	// Manually test the sequential application logic.
 	currentContent := content
 

internal/agent/tools/view.go ๐Ÿ”—

@@ -47,7 +47,13 @@ const (
 	MaxLineLength    = 2000
 )
 
-func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool {
+func NewViewTool(
+	lspClients *csync.Map[string, *lsp.Client],
+	permissions permission.Service,
+	filetracker filetracker.Service,
+	workingDir string,
+	skillsPaths ...string,
+) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		ViewToolName,
 		string(viewDescription),
@@ -74,13 +80,13 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
 			isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
 			isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
 
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
+			}
+
 			// Request permission for files outside working directory, unless it's a skill file.
 			if isOutsideWorkDir && !isSkillFile {
-				sessionID := GetSessionFromContext(ctx)
-				if sessionID == "" {
-					return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
-				}
-
 				granted, err := permissions.Request(ctx,
 					permission.CreatePermissionRequest{
 						SessionID:   sessionID,
@@ -190,7 +196,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
 			}
 			output += "\n</file>\n"
 			output += getDiagnostics(filePath, lspClients)
-			filetracker.RecordRead(filePath)
+			filetracker.RecordRead(ctx, sessionID, filePath)
 			return fantasy.WithResponseMetadata(
 				fantasy.NewTextResponse(output),
 				ViewResponseMetadata{

internal/agent/tools/write.go ๐Ÿ”—

@@ -44,7 +44,13 @@ type WriteResponseMetadata struct {
 
 const WriteToolName = "write"
 
-func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+func NewWriteTool(
+	lspClients *csync.Map[string, *lsp.Client],
+	permissions permission.Service,
+	files history.Service,
+	filetracker filetracker.Service,
+	workingDir string,
+) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		WriteToolName,
 		string(writeDescription),
@@ -57,6 +63,11 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
 				return fantasy.NewTextErrorResponse("content is required"), nil
 			}
 
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
+			}
+
 			filePath := filepathext.SmartJoin(workingDir, params.FilePath)
 
 			fileInfo, err := os.Stat(filePath)
@@ -65,8 +76,8 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
 					return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
 				}
 
-				modTime := fileInfo.ModTime()
-				lastRead := filetracker.LastReadTime(filePath)
+				modTime := fileInfo.ModTime().Truncate(time.Second)
+				lastRead := filetracker.LastReadTime(ctx, sessionID, filePath)
 				if modTime.After(lastRead) {
 					return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
 						filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
@@ -93,11 +104,6 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
 				}
 			}
 
-			sessionID := GetSessionFromContext(ctx)
-			if sessionID == "" {
-				return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
-			}
-
 			diff, additions, removals := diff.GenerateDiff(
 				oldContent,
 				params.Content,
@@ -153,8 +159,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
 				slog.Error("Error creating file history version", "error", err)
 			}
 
-			filetracker.RecordWrite(filePath)
-			filetracker.RecordRead(filePath)
+			filetracker.RecordRead(ctx, sessionID, filePath)
 
 			notifyLSPs(ctx, lspClients, params.FilePath)
 

internal/app/app.go ๐Ÿ”—

@@ -15,14 +15,15 @@ import (
 	"time"
 
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/fantasy"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/format"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/log"
@@ -53,6 +54,7 @@ type App struct {
 	Messages    message.Service
 	History     history.Service
 	Permissions permission.Service
+	FileTracker filetracker.Service
 
 	AgentCoordinator agent.Coordinator
 
@@ -87,6 +89,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 		Messages:    messages,
 		History:     files,
 		Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
+		FileTracker: filetracker.NewService(q),
 		LSPClients:  csync.NewMap[string, *lsp.Client](),
 
 		globalCtx: ctx,
@@ -132,7 +135,7 @@ func (app *App) Config() *config.Config {
 
 // RunNonInteractive runs the application in non-interactive mode with the
 // given prompt, printing to stdout.
-func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, quiet bool) error {
+func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
 	slog.Info("Running in non-interactive mode")
 
 	ctx, cancel := context.WithCancel(ctx)
@@ -149,6 +152,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 		stdoutTTY bool
 		stderrTTY bool
 		stdinTTY  bool
+		progress  bool
 	)
 
 	if f, ok := output.(*os.File); ok {
@@ -156,8 +160,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 	}
 	stderrTTY = term.IsTerminal(os.Stderr.Fd())
 	stdinTTY = term.IsTerminal(os.Stdin.Fd())
+	progress = app.config.Options.Progress == nil || *app.config.Options.Progress
 
-	if !quiet && stderrTTY {
+	if !hideSpinner && stderrTTY {
 		t := styles.CurrentTheme()
 
 		// Detect background color to set the appropriate color for the
@@ -182,7 +187,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 
 	// Helper function to stop spinner once.
 	stopSpinner := func() {
-		if !quiet && spinner != nil {
+		if !hideSpinner && spinner != nil {
 			spinner.Stop()
 			spinner = nil
 		}
@@ -239,9 +244,10 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 
 	messageEvents := app.Messages.Subscribe(ctx)
 	messageReadBytes := make(map[string]int)
+	var printed bool
 
 	defer func() {
-		if stderrTTY {
+		if progress && stderrTTY {
 			_, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
 		}
 
@@ -251,7 +257,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 	}()
 
 	for {
-		if stderrTTY {
+		if progress && 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)
@@ -262,7 +268,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 			stopSpinner()
 			if result.err != nil {
 				if errors.Is(result.err, context.Canceled) || errors.Is(result.err, agent.ErrRequestCancelled) {
-					slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
+					slog.Debug("Non-interactive: agent processing cancelled", "session_id", sess.ID)
 					return nil
 				}
 				return fmt.Errorf("agent processing failed: %w", result.err)
@@ -288,7 +294,11 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 				if readBytes == 0 {
 					part = strings.TrimLeft(part, " \t")
 				}
-				fmt.Fprint(output, part)
+				// Ignore initial whitespace-only messages.
+				if printed || strings.TrimSpace(part) != "" {
+					printed = true
+					fmt.Fprint(output, part)
+				}
 				messageReadBytes[msg.ID] = len(content)
 			}
 
@@ -427,20 +437,20 @@ func setupSubscriber[T any](
 			select {
 			case event, ok := <-subCh:
 				if !ok {
-					slog.Debug("subscription channel closed", "name", name)
+					slog.Debug("Subscription channel closed", "name", name)
 					return
 				}
 				var msg tea.Msg = event
 				select {
 				case outputCh <- msg:
 				case <-time.After(2 * time.Second):
-					slog.Warn("message dropped due to slow consumer", "name", name)
+					slog.Debug("Message dropped due to slow consumer", "name", name)
 				case <-ctx.Done():
-					slog.Debug("subscription cancelled", "name", name)
+					slog.Debug("Subscription cancelled", "name", name)
 					return
 				}
 			case <-ctx.Done():
-				slog.Debug("subscription cancelled", "name", name)
+				slog.Debug("Subscription cancelled", "name", name)
 				return
 			}
 		}
@@ -460,6 +470,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
 		app.Messages,
 		app.Permissions,
 		app.History,
+		app.FileTracker,
 		app.LSPClients,
 	)
 	if err != nil {
@@ -504,7 +515,7 @@ 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()) }()
+	defer func() { slog.Debug("Shutdown took " + time.Since(start).String()) }()
 
 	// First, cancel all agents and wait for them to finish. This must complete
 	// before closing the DB so agents can finish writing their state.

internal/app/lsp.go ๐Ÿ”—

@@ -140,7 +140,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
 		updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
 	}
 
-	slog.Info("LSP client initialized", "name", name)
+	slog.Debug("LSP client initialized", "name", name)
 
 	// Add to map with mutex protection before starting goroutine
 	app.LSPClients.Set(name, lspClient)

internal/app/provider_test.go ๐Ÿ”—

@@ -3,7 +3,7 @@ package app
 import (
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/stretchr/testify/require"
 )

internal/cmd/models.go ๐Ÿ”—

@@ -7,8 +7,8 @@ import (
 	"sort"
 	"strings"
 
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2/tree"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/mattn/go-isatty"
 	"github.com/spf13/cobra"

internal/cmd/root.go ๐Ÿ”—

@@ -179,12 +179,19 @@ func supportsProgressBar() bool {
 }
 
 func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
-	if supportsProgressBar() {
+	app, err := setupApp(cmd)
+	if err != nil {
+		return nil, err
+	}
+
+	// Check if progress bar is enabled in config (defaults to true if nil)
+	progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
+	if progressEnabled && supportsProgressBar() {
 		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
 		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
 	}
 
-	return setupApp(cmd)
+	return app, nil
 }
 
 // setupApp handles the common setup logic for both interactive and non-interactive modes.

internal/cmd/run.go ๐Ÿ”—

@@ -8,6 +8,7 @@ import (
 	"os/signal"
 	"strings"
 
+	"charm.land/log/v2"
 	"github.com/charmbracelet/crush/internal/event"
 	"github.com/spf13/cobra"
 )
@@ -29,9 +30,13 @@ crush run "What is this code doing?" <<< prrr.go
 
 # Run in quiet mode (hide the spinner)
 crush run --quiet "Generate a README for this project"
+
+# Run in verbose mode
+crush run --verbose "Generate a README for this project"
   `,
 	RunE: func(cmd *cobra.Command, args []string) error {
 		quiet, _ := cmd.Flags().GetBool("quiet")
+		verbose, _ := cmd.Flags().GetBool("verbose")
 		largeModel, _ := cmd.Flags().GetString("model")
 		smallModel, _ := cmd.Flags().GetString("small-model")
 
@@ -49,6 +54,10 @@ crush run --quiet "Generate a README for this project"
 			return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
 		}
 
+		if verbose {
+			slog.SetDefault(slog.New(log.New(os.Stderr)))
+		}
+
 		prompt := strings.Join(args, " ")
 
 		prompt, err = MaybePrependStdin(prompt)
@@ -64,7 +73,7 @@ crush run --quiet "Generate a README for this project"
 		event.SetNonInteractive(true)
 		event.AppInitialized()
 
-		return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet)
+		return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose)
 	},
 	PostRun: func(cmd *cobra.Command, args []string) {
 		event.AppExited()
@@ -73,6 +82,7 @@ crush run --quiet "Generate a README for this project"
 
 func init() {
 	runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner")
+	runCmd.Flags().BoolP("verbose", "v", false, "Show logs")
 	runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
 	runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider")
 }

internal/cmd/stats/index.css ๐Ÿ”—

@@ -189,20 +189,15 @@ body {
 }
 
 .chart-row {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
+  display: flex;
+  flex-wrap: wrap;
   gap: 1.5rem;
   width: 100%;
 }
 
 .chart-row .chart-card {
-  width: 100%;
-}
-
-@media (max-width: 1024px) {
-  .chart-row {
-    grid-template-columns: 1fr;
-  }
+  flex: 1 1 300px;
+  max-width: calc((100% - 1.5rem) / 2);
 }
 
 .chart-card h2 {

internal/config/catwalk.go ๐Ÿ”—

@@ -7,8 +7,8 @@ import (
 	"sync"
 	"sync/atomic"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/catwalk/pkg/embedded"
+	"charm.land/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/embedded"
 )
 
 type catwalkClient interface {

internal/config/catwalk_test.go ๐Ÿ”—

@@ -7,7 +7,7 @@ import (
 	"os"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/stretchr/testify/require"
 )
 

internal/config/config.go ๐Ÿ”—

@@ -14,7 +14,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
@@ -257,7 +257,8 @@ type Options struct {
 	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"`
 	InitializeAs              string       `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
-	AutoLSP                   *bool        `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers"`
+	AutoLSP                   *bool        `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
+	Progress                  *bool        `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
 }
 
 type MCPs map[string]MCPConfig
@@ -316,7 +317,7 @@ func (m MCPConfig) ResolvedHeaders() map[string]string {
 		var err error
 		m.Headers[e], err = resolver.ResolveValue(v)
 		if err != nil {
-			slog.Error("error resolving header variable", "error", err, "variable", e, "value", v)
+			slog.Error("Error resolving header variable", "error", err, "variable", e, "value", v)
 			continue
 		}
 	}
@@ -839,7 +840,7 @@ func resolveEnvs(envs map[string]string) []string {
 		var err error
 		envs[e], err = resolver.ResolveValue(v)
 		if err != nil {
-			slog.Error("error resolving environment variable", "error", err, "variable", e, "value", v)
+			slog.Error("Error resolving environment variable", "error", err, "variable", e, "value", v)
 			continue
 		}
 	}

internal/config/copilot.go ๐Ÿ”—

@@ -6,7 +6,7 @@ import (
 	"log/slog"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/oauth/copilot"
 )

internal/config/docker_mcp_test.go ๐Ÿ”—

@@ -5,7 +5,7 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/env"
 	"github.com/stretchr/testify/require"
 )

internal/config/hyper.go ๐Ÿ”—

@@ -11,7 +11,7 @@ import (
 	"sync/atomic"
 	"time"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/hyper"
 	xetag "github.com/charmbracelet/x/etag"
 )

internal/config/hyper_test.go ๐Ÿ”—

@@ -7,7 +7,7 @@ import (
 	"os"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/stretchr/testify/require"
 )
 

internal/config/load.go ๐Ÿ”—

@@ -16,7 +16,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"

internal/config/load_test.go ๐Ÿ”—

@@ -7,7 +7,7 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
 	"github.com/stretchr/testify/assert"

internal/config/provider.go ๐Ÿ”—

@@ -15,8 +15,8 @@ import (
 	"sync"
 	"time"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/catwalk/pkg/embedded"
+	"charm.land/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/embedded"
 	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/home"

internal/config/provider_test.go ๐Ÿ”—

@@ -7,7 +7,7 @@ import (
 	"sync"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/stretchr/testify/require"
 )
 

internal/db/db.go ๐Ÿ”—

@@ -57,6 +57,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
 	if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil {
 		return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err)
 	}
+	if q.getFileReadStmt, err = db.PrepareContext(ctx, getFileRead); err != nil {
+		return nil, fmt.Errorf("error preparing query GetFileRead: %w", err)
+	}
 	if q.getHourDayHeatmapStmt, err = db.PrepareContext(ctx, getHourDayHeatmap); err != nil {
 		return nil, fmt.Errorf("error preparing query GetHourDayHeatmap: %w", err)
 	}
@@ -111,6 +114,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
 	if q.listUserMessagesBySessionStmt, err = db.PrepareContext(ctx, listUserMessagesBySession); err != nil {
 		return nil, fmt.Errorf("error preparing query ListUserMessagesBySession: %w", err)
 	}
+	if q.recordFileReadStmt, err = db.PrepareContext(ctx, recordFileRead); err != nil {
+		return nil, fmt.Errorf("error preparing query RecordFileRead: %w", err)
+	}
 	if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
 		return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
 	}
@@ -180,6 +186,11 @@ func (q *Queries) Close() error {
 			err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr)
 		}
 	}
+	if q.getFileReadStmt != nil {
+		if cerr := q.getFileReadStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing getFileReadStmt: %w", cerr)
+		}
+	}
 	if q.getHourDayHeatmapStmt != nil {
 		if cerr := q.getHourDayHeatmapStmt.Close(); cerr != nil {
 			err = fmt.Errorf("error closing getHourDayHeatmapStmt: %w", cerr)
@@ -270,6 +281,11 @@ func (q *Queries) Close() error {
 			err = fmt.Errorf("error closing listUserMessagesBySessionStmt: %w", cerr)
 		}
 	}
+	if q.recordFileReadStmt != nil {
+		if cerr := q.recordFileReadStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing recordFileReadStmt: %w", cerr)
+		}
+	}
 	if q.updateMessageStmt != nil {
 		if cerr := q.updateMessageStmt.Close(); cerr != nil {
 			err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -335,6 +351,7 @@ type Queries struct {
 	getAverageResponseTimeStmt     *sql.Stmt
 	getFileStmt                    *sql.Stmt
 	getFileByPathAndSessionStmt    *sql.Stmt
+	getFileReadStmt                *sql.Stmt
 	getHourDayHeatmapStmt          *sql.Stmt
 	getMessageStmt                 *sql.Stmt
 	getRecentActivityStmt          *sql.Stmt
@@ -353,6 +370,7 @@ type Queries struct {
 	listNewFilesStmt               *sql.Stmt
 	listSessionsStmt               *sql.Stmt
 	listUserMessagesBySessionStmt  *sql.Stmt
+	recordFileReadStmt             *sql.Stmt
 	updateMessageStmt              *sql.Stmt
 	updateSessionStmt              *sql.Stmt
 	updateSessionTitleAndUsageStmt *sql.Stmt
@@ -373,6 +391,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
 		getAverageResponseTimeStmt:     q.getAverageResponseTimeStmt,
 		getFileStmt:                    q.getFileStmt,
 		getFileByPathAndSessionStmt:    q.getFileByPathAndSessionStmt,
+		getFileReadStmt:                q.getFileReadStmt,
 		getHourDayHeatmapStmt:          q.getHourDayHeatmapStmt,
 		getMessageStmt:                 q.getMessageStmt,
 		getRecentActivityStmt:          q.getRecentActivityStmt,
@@ -391,6 +410,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
 		listNewFilesStmt:               q.listNewFilesStmt,
 		listSessionsStmt:               q.listSessionsStmt,
 		listUserMessagesBySessionStmt:  q.listUserMessagesBySessionStmt,
+		recordFileReadStmt:             q.recordFileReadStmt,
 		updateMessageStmt:              q.updateMessageStmt,
 		updateSessionStmt:              q.updateSessionStmt,
 		updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,

internal/db/migrations/20260127000000_add_read_files_table.sql ๐Ÿ”—

@@ -0,0 +1,20 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE IF NOT EXISTS read_files (
+    session_id TEXT NOT NULL CHECK (session_id != ''),
+    path TEXT NOT NULL CHECK (path != ''),
+    read_at INTEGER NOT NULL,  -- Unix timestamp in seconds when file was last read
+    FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
+    PRIMARY KEY (path, session_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_read_files_session_id ON read_files (session_id);
+CREATE INDEX IF NOT EXISTS idx_read_files_path ON read_files (path);
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP INDEX IF EXISTS idx_read_files_path;
+DROP INDEX IF EXISTS idx_read_files_session_id;
+DROP TABLE IF EXISTS read_files;
+-- +goose StatementEnd

internal/db/models.go ๐Ÿ”—

@@ -31,6 +31,12 @@ type Message struct {
 	IsSummaryMessage int64          `json:"is_summary_message"`
 }
 
+type ReadFile struct {
+	SessionID string `json:"session_id"`
+	Path      string `json:"path"`
+	ReadAt    int64  `json:"read_at"` // Unix timestamp when file was last read
+}
+
 type Session struct {
 	ID               string         `json:"id"`
 	ParentSessionID  sql.NullString `json:"parent_session_id"`

internal/db/querier.go ๐Ÿ”—

@@ -20,6 +20,7 @@ type Querier interface {
 	GetAverageResponseTime(ctx context.Context) (int64, error)
 	GetFile(ctx context.Context, id string) (File, error)
 	GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
+	GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error)
 	GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
 	GetMessage(ctx context.Context, id string) (Message, error)
 	GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
@@ -38,6 +39,7 @@ type Querier interface {
 	ListNewFiles(ctx context.Context) ([]File, error)
 	ListSessions(ctx context.Context) ([]Session, error)
 	ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
+	RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
 	UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
 	UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
 	UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error

internal/db/read_files.sql.go ๐Ÿ”—

@@ -0,0 +1,57 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.30.0
+// source: read_files.sql
+
+package db
+
+import (
+	"context"
+)
+
+const getFileRead = `-- name: GetFileRead :one
+SELECT session_id, path, read_at FROM read_files
+WHERE session_id = ? AND path = ? LIMIT 1
+`
+
+type GetFileReadParams struct {
+	SessionID string `json:"session_id"`
+	Path      string `json:"path"`
+}
+
+func (q *Queries) GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error) {
+	row := q.queryRow(ctx, q.getFileReadStmt, getFileRead, arg.SessionID, arg.Path)
+	var i ReadFile
+	err := row.Scan(
+		&i.SessionID,
+		&i.Path,
+		&i.ReadAt,
+	)
+	return i, err
+}
+
+const recordFileRead = `-- name: RecordFileRead :exec
+INSERT INTO read_files (
+    session_id,
+    path,
+    read_at
+) VALUES (
+    ?,
+    ?,
+    strftime('%s', 'now')
+) ON CONFLICT(path, session_id) DO UPDATE SET
+    read_at = excluded.read_at
+`
+
+type RecordFileReadParams struct {
+	SessionID string `json:"session_id"`
+	Path      string `json:"path"`
+}
+
+func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
+	_, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead,
+		arg.SessionID,
+		arg.Path,
+	)
+	return err
+}

internal/db/sql/read_files.sql ๐Ÿ”—

@@ -0,0 +1,15 @@
+-- name: RecordFileRead :exec
+INSERT INTO read_files (
+    session_id,
+    path,
+    read_at
+) VALUES (
+    ?,
+    ?,
+    strftime('%s', 'now')
+) ON CONFLICT(path, session_id) DO UPDATE SET
+    read_at = excluded.read_at;
+
+-- name: GetFileRead :one
+SELECT * FROM read_files
+WHERE session_id = ? AND path = ? LIMIT 1;

internal/event/event.go ๐Ÿ”—

@@ -82,18 +82,18 @@ func send(event string, props ...any) {
 }
 
 // Error logs an error event to PostHog with the error type and message.
-func Error(err any, props ...any) {
+func Error(errToLog any, props ...any) {
 	if client == nil {
 		return
 	}
 	posthogErr := client.Enqueue(posthog.NewDefaultException(
 		time.Now(),
 		distinctId,
-		reflect.TypeOf(err).String(),
-		fmt.Sprintf("%v", err),
+		reflect.TypeOf(errToLog).String(),
+		fmt.Sprintf("%v", errToLog),
 	))
-	if err != nil {
-		slog.Error("Failed to enqueue PostHog error", "err", err, "props", props, "posthogErr", posthogErr)
+	if posthogErr != nil {
+		slog.Error("Failed to enqueue PostHog error", "err", errToLog, "props", props, "posthogErr", posthogErr)
 		return
 	}
 }

internal/event/event_test.go ๐Ÿ”—

@@ -0,0 +1,74 @@
+package event
+
+// These tests verify that the Error function correctly handles various
+// scenarios. These tests will not log anything.
+
+import (
+	"testing"
+)
+
+func TestError(t *testing.T) {
+	t.Run("returns early when client is nil", func(t *testing.T) {
+		// This test verifies that when the PostHog client is not initialized
+		// the Error function safely returns early without attempting to
+		// enqueue any events. This is important during initialization or when
+		// metrics are disabled, as we don't want the error reporting mechanism
+		// itself to cause panics.
+		originalClient := client
+		defer func() {
+			client = originalClient
+		}()
+
+		client = nil
+		Error("test error", "key", "value")
+	})
+
+	t.Run("handles nil client without panicking", func(t *testing.T) {
+		// This test covers various edge cases where the error value might be
+		// nil, a string, or an error type.
+		originalClient := client
+		defer func() {
+			client = originalClient
+		}()
+
+		client = nil
+		Error(nil)
+		Error("some error")
+		Error(newDefaultTestError("runtime error"), "key", "value")
+	})
+
+	t.Run("handles error with properties", func(t *testing.T) {
+		// This test verifies that the Error function can handle additional
+		// key-value properties that provide context about the error. These
+		// properties are typically passed when recovering from panics (i.e.,
+		// panic name, function name).
+		//
+		// Even with these additional properties, the function should handle
+		// them gracefully without panicking.
+		originalClient := client
+		defer func() {
+			client = originalClient
+		}()
+
+		client = nil
+		Error("test error",
+			"type", "test",
+			"severity", "high",
+			"source", "unit-test",
+		)
+	})
+}
+
+// newDefaultTestError creates a test error that mimics runtime panic
+// errors. This helps us testing that the Error function can handle various
+// error types, including those that might be passed from a panic recovery
+// scenario.
+func newDefaultTestError(s string) error {
+	return testError(s)
+}
+
+type testError string
+
+func (e testError) Error() string {
+	return string(e)
+}

internal/filetracker/filetracker.go ๐Ÿ”—

@@ -1,70 +0,0 @@
-// Package filetracker tracks file read/write times to prevent editing files
-// that haven't been read, and to detect external modifications.
-//
-// TODO: Consider moving this to persistent storage (e.g., the database) to
-// preserve file access history across sessions.
-// We would need to make sure to handle the case where we reload a session and the underlying files did change.
-package filetracker
-
-import (
-	"sync"
-	"time"
-)
-
-// record tracks when a file was read/written.
-type record struct {
-	path      string
-	readTime  time.Time
-	writeTime time.Time
-}
-
-var (
-	records     = make(map[string]record)
-	recordMutex sync.RWMutex
-)
-
-// RecordRead records when a file was read.
-func RecordRead(path string) {
-	recordMutex.Lock()
-	defer recordMutex.Unlock()
-
-	rec, exists := records[path]
-	if !exists {
-		rec = record{path: path}
-	}
-	rec.readTime = time.Now()
-	records[path] = rec
-}
-
-// LastReadTime returns when a file was last read. Returns zero time if never
-// read.
-func LastReadTime(path string) time.Time {
-	recordMutex.RLock()
-	defer recordMutex.RUnlock()
-
-	rec, exists := records[path]
-	if !exists {
-		return time.Time{}
-	}
-	return rec.readTime
-}
-
-// RecordWrite records when a file was written.
-func RecordWrite(path string) {
-	recordMutex.Lock()
-	defer recordMutex.Unlock()
-
-	rec, exists := records[path]
-	if !exists {
-		rec = record{path: path}
-	}
-	rec.writeTime = time.Now()
-	records[path] = rec
-}
-
-// Reset clears all file tracking records. Useful for testing.
-func Reset() {
-	recordMutex.Lock()
-	defer recordMutex.Unlock()
-	records = make(map[string]record)
-}

internal/filetracker/service.go ๐Ÿ”—

@@ -0,0 +1,70 @@
+// Package filetracker provides functionality to track file reads in sessions.
+package filetracker
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/db"
+)
+
+// Service defines the interface for tracking file reads in sessions.
+type Service interface {
+	// RecordRead records when a file was read.
+	RecordRead(ctx context.Context, sessionID, path string)
+
+	// LastReadTime returns when a file was last read.
+	// Returns zero time if never read.
+	LastReadTime(ctx context.Context, sessionID, path string) time.Time
+}
+
+type service struct {
+	q *db.Queries
+}
+
+// NewService creates a new file tracker service.
+func NewService(q *db.Queries) Service {
+	return &service{q: q}
+}
+
+// RecordRead records when a file was read.
+func (s *service) RecordRead(ctx context.Context, sessionID, path string) {
+	if err := s.q.RecordFileRead(ctx, db.RecordFileReadParams{
+		SessionID: sessionID,
+		Path:      relpath(path),
+	}); err != nil {
+		slog.Error("Error recording file read", "error", err, "file", path)
+	}
+}
+
+// LastReadTime returns when a file was last read.
+// Returns zero time if never read.
+func (s *service) LastReadTime(ctx context.Context, sessionID, path string) time.Time {
+	readFile, err := s.q.GetFileRead(ctx, db.GetFileReadParams{
+		SessionID: sessionID,
+		Path:      relpath(path),
+	})
+	if err != nil {
+		return time.Time{}
+	}
+
+	return time.Unix(readFile.ReadAt, 0)
+}
+
+func relpath(path string) string {
+	path = filepath.Clean(path)
+	basepath, err := os.Getwd()
+	if err != nil {
+		slog.Warn("Error getting basepath", "error", err)
+		return path
+	}
+	relpath, err := filepath.Rel(basepath, path)
+	if err != nil {
+		slog.Warn("Error getting relpath", "error", err)
+		return path
+	}
+	return relpath
+}

internal/filetracker/service_test.go ๐Ÿ”—

@@ -0,0 +1,116 @@
+package filetracker
+
+import (
+	"context"
+	"testing"
+	"testing/synctest"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/db"
+	"github.com/stretchr/testify/require"
+)
+
+type testEnv struct {
+	ctx context.Context
+	q   *db.Queries
+	svc Service
+}
+
+func setupTest(t *testing.T) *testEnv {
+	t.Helper()
+
+	conn, err := db.Connect(t.Context(), t.TempDir())
+	require.NoError(t, err)
+	t.Cleanup(func() { conn.Close() })
+
+	q := db.New(conn)
+	return &testEnv{
+		ctx: t.Context(),
+		q:   q,
+		svc: NewService(q),
+	}
+}
+
+func (e *testEnv) createSession(t *testing.T, sessionID string) {
+	t.Helper()
+	_, err := e.q.CreateSession(e.ctx, db.CreateSessionParams{
+		ID:    sessionID,
+		Title: "Test Session",
+	})
+	require.NoError(t, err)
+}
+
+func TestService_RecordRead(t *testing.T) {
+	env := setupTest(t)
+
+	sessionID := "test-session-1"
+	path := "/path/to/file.go"
+	env.createSession(t, sessionID)
+
+	env.svc.RecordRead(env.ctx, sessionID, path)
+
+	lastRead := env.svc.LastReadTime(env.ctx, sessionID, path)
+	require.False(t, lastRead.IsZero(), "expected non-zero time after recording read")
+	require.WithinDuration(t, time.Now(), lastRead, 2*time.Second)
+}
+
+func TestService_LastReadTime_NotFound(t *testing.T) {
+	env := setupTest(t)
+
+	lastRead := env.svc.LastReadTime(env.ctx, "nonexistent-session", "/nonexistent/path")
+	require.True(t, lastRead.IsZero(), "expected zero time for unread file")
+}
+
+func TestService_RecordRead_UpdatesTimestamp(t *testing.T) {
+	env := setupTest(t)
+
+	sessionID := "test-session-2"
+	path := "/path/to/file.go"
+	env.createSession(t, sessionID)
+
+	env.svc.RecordRead(env.ctx, sessionID, path)
+	firstRead := env.svc.LastReadTime(env.ctx, sessionID, path)
+	require.False(t, firstRead.IsZero())
+
+	synctest.Test(t, func(t *testing.T) {
+		time.Sleep(100 * time.Millisecond)
+		synctest.Wait()
+		env.svc.RecordRead(env.ctx, sessionID, path)
+		secondRead := env.svc.LastReadTime(env.ctx, sessionID, path)
+
+		require.False(t, secondRead.Before(firstRead), "second read time should not be before first")
+	})
+}
+
+func TestService_RecordRead_DifferentSessions(t *testing.T) {
+	env := setupTest(t)
+
+	path := "/shared/file.go"
+	session1, session2 := "session-1", "session-2"
+	env.createSession(t, session1)
+	env.createSession(t, session2)
+
+	env.svc.RecordRead(env.ctx, session1, path)
+
+	lastRead1 := env.svc.LastReadTime(env.ctx, session1, path)
+	require.False(t, lastRead1.IsZero())
+
+	lastRead2 := env.svc.LastReadTime(env.ctx, session2, path)
+	require.True(t, lastRead2.IsZero(), "session 2 should not see session 1's read")
+}
+
+func TestService_RecordRead_DifferentPaths(t *testing.T) {
+	env := setupTest(t)
+
+	sessionID := "test-session-3"
+	path1, path2 := "/path/to/file1.go", "/path/to/file2.go"
+	env.createSession(t, sessionID)
+
+	env.svc.RecordRead(env.ctx, sessionID, path1)
+
+	lastRead1 := env.svc.LastReadTime(env.ctx, sessionID, path1)
+	require.False(t, lastRead1.IsZero())
+
+	lastRead2 := env.svc.LastReadTime(env.ctx, sessionID, path2)
+	require.True(t, lastRead2.IsZero(), "path2 should not be recorded")
+}

internal/fsext/ls.go ๐Ÿ”—

@@ -144,20 +144,20 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
 	}
 
 	if commonIgnorePatterns().MatchesPath(relPath) {
-		slog.Debug("ignoring common pattern", "path", relPath)
+		slog.Debug("Ignoring common pattern", "path", relPath)
 		return true
 	}
 
 	parentDir := filepath.Dir(path)
 	ignoreParser := dl.getIgnore(parentDir)
 	if ignoreParser.MatchesPath(relPath) {
-		slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
+		slog.Debug("Ignoring dir pattern", "path", relPath, "dir", parentDir)
 		return true
 	}
 
 	// For directories, also check with trailing slash (gitignore convention)
 	if ignoreParser.MatchesPath(relPath + "/") {
-		slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
+		slog.Debug("Ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
 		return true
 	}
 
@@ -166,7 +166,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
 	}
 
 	if homeIgnore().MatchesPath(relPath) {
-		slog.Debug("ignoring home dir pattern", "path", relPath)
+		slog.Debug("Ignoring home dir pattern", "path", relPath)
 		return true
 	}
 
@@ -177,7 +177,7 @@ func (dl *directoryLister) checkParentIgnores(path string) bool {
 	parent := filepath.Dir(filepath.Dir(path))
 	for parent != "." && path != "." {
 		if dl.getIgnore(parent).MatchesPath(path) {
-			slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
+			slog.Debug("Ignoring parent dir pattern", "path", path, "dir", parent)
 			return true
 		}
 		if parent == dl.rootPath {
@@ -210,7 +210,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int
 	found := csync.NewSlice[string]()
 	dl := NewDirectoryLister(initialPath)
 
-	slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
+	slog.Debug("Listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
 
 	conf := fastwalk.Config{
 		Follow:   true,

internal/home/home.go ๐Ÿ”—

@@ -12,7 +12,7 @@ var homedir, homedirErr = os.UserHomeDir()
 
 func init() {
 	if homedirErr != nil {
-		slog.Error("failed to get user home directory", "error", homedirErr)
+		slog.Error("Failed to get user home directory", "error", homedirErr)
 	}
 }
 

internal/lsp/client.go ๐Ÿ”—

@@ -317,12 +317,12 @@ func (c *Client) HandlesFile(path string) bool {
 	// Check if file is within working directory.
 	absPath, err := filepath.Abs(path)
 	if err != nil {
-		slog.Debug("cannot resolve path", "name", c.name, "file", path, "error", err)
+		slog.Debug("Cannot resolve path", "name", c.name, "file", path, "error", err)
 		return false
 	}
 	relPath, err := filepath.Rel(c.workDir, absPath)
 	if err != nil || strings.HasPrefix(relPath, "..") {
-		slog.Debug("file outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
+		slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
 		return false
 	}
 
@@ -339,11 +339,11 @@ func (c *Client) HandlesFile(path string) bool {
 			suffix = "." + suffix
 		}
 		if strings.HasSuffix(name, suffix) || filetype == string(kind) {
-			slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
+			slog.Debug("Handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
 			return true
 		}
 	}
-	slog.Debug("doesn't handle file", "name", c.name, "file", name)
+	slog.Debug("Doesn't handle file", "name", c.name, "file", name)
 	return false
 }
 

internal/message/content.go ๐Ÿ”—

@@ -8,11 +8,11 @@ import (
 	"strings"
 	"time"
 
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/fantasy"
 	"charm.land/fantasy/providers/anthropic"
 	"charm.land/fantasy/providers/google"
 	"charm.land/fantasy/providers/openai"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 )
 
 type MessageRole string

internal/session/session.go ๐Ÿ”—

@@ -203,7 +203,7 @@ 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)
+		slog.Error("Failed to unmarshal todos", "session_id", item.ID, "error", err)
 	}
 	return Session{
 		ID:               item.ID,

internal/stringext/string.go ๐Ÿ”—

@@ -1,6 +1,8 @@
 package stringext
 
 import (
+	"strings"
+
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 )
@@ -8,3 +10,13 @@ import (
 func Capitalize(text string) string {
 	return cases.Title(language.English, cases.Compact).String(text)
 }
+
+// NormalizeSpace normalizes whitespace in the given content string.
+// It replaces Windows-style line endings with Unix-style line endings,
+// converts tabs to four spaces, and trims leading and trailing whitespace.
+func NormalizeSpace(content string) string {
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	content = strings.ReplaceAll(content, "\t", "    ")
+	content = strings.TrimSpace(content)
+	return content
+}

internal/tui/components/chat/editor/editor.go ๐Ÿ”—

@@ -1,6 +1,7 @@
 package editor
 
 import (
+	"context"
 	"fmt"
 	"math/rand"
 	"net/http"
@@ -17,7 +18,6 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/app"
-	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/session"
@@ -66,6 +66,7 @@ type editorCmp struct {
 	x, y               int
 	app                *app.App
 	session            session.Session
+	sessionFileReads   []string
 	textarea           textarea.Model
 	attachments        []message.Attachment
 	deleteMode         bool
@@ -181,6 +182,9 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
+	case chat.SessionClearedMsg:
+		m.session = session.Session{}
+		m.sessionFileReads = nil
 	case tea.WindowSizeMsg:
 		return m, m.repositionCompletions
 	case filepicker.FilePickedMsg:
@@ -212,19 +216,27 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				m.completionsStartIndex = 0
 			}
 			absPath, _ := filepath.Abs(item.Path)
+
+			ctx := context.Background()
+
 			// Skip attachment if file was already read and hasn't been modified.
-			lastRead := filetracker.LastReadTime(absPath)
-			if !lastRead.IsZero() {
-				if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
-					return m, nil
+			if m.session.ID != "" {
+				lastRead := m.app.FileTracker.LastReadTime(ctx, m.session.ID, absPath)
+				if !lastRead.IsZero() {
+					if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
+						return m, nil
+					}
 				}
+			} else if slices.Contains(m.sessionFileReads, absPath) {
+				return m, nil
 			}
+
+			m.sessionFileReads = append(m.sessionFileReads, absPath)
 			content, err := os.ReadFile(item.Path)
 			if err != nil {
 				// if it fails, let the LLM handle it later.
 				return m, nil
 			}
-			filetracker.RecordRead(absPath)
 			m.attachments = append(m.attachments, message.Attachment{
 				FilePath: item.Path,
 				FileName: filepath.Base(item.Path),
@@ -662,6 +674,9 @@ func (c *editorCmp) Bindings() []key.Binding {
 // we need to move some functionality to the page level
 func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
 	c.session = session
+	for _, path := range c.sessionFileReads {
+		c.app.FileTracker.RecordRead(context.Background(), session.ID, path)
+	}
 	return nil
 }
 

internal/tui/components/chat/messages/messages.go ๐Ÿ”—

@@ -9,8 +9,8 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/viewport"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/ordered"
 	"github.com/google/uuid"

internal/tui/components/chat/sidebar/sidebar.go ๐Ÿ”—

@@ -8,7 +8,6 @@ import (
 
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
@@ -548,7 +547,6 @@ func (s *sidebarCmp) currentModelBlock() string {
 	selectedModel := cfg.Models[agentCfg.Model]
 
 	model := config.Get().GetModelByType(agentCfg.Model)
-	modelProvider := config.Get().GetProviderForModel(agentCfg.Model)
 
 	t := styles.CurrentTheme()
 
@@ -560,15 +558,14 @@ func (s *sidebarCmp) currentModelBlock() string {
 	}
 	if model.CanReason {
 		reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
-		switch modelProvider.Type {
-		case catwalk.TypeAnthropic:
+		if len(model.ReasoningLevels) == 0 {
 			formatter := cases.Title(language.English, cases.NoLower)
 			if selectedModel.Think {
 				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
 			} else {
 				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
 			}
-		default:
+		} else {
 			reasoningEffort := model.DefaultReasoningEffort
 			if selectedModel.ReasoningEffort != "" {
 				reasoningEffort = selectedModel.ReasoningEffort

internal/tui/components/chat/splash/splash.go ๐Ÿ”—

@@ -8,8 +8,8 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/spinner"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent"
 	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/config"

internal/tui/components/dialogs/commands/commands.go ๐Ÿ”—

@@ -10,10 +10,8 @@ import (
 	"charm.land/bubbles/v2/key"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 
 	"github.com/charmbracelet/crush/internal/agent"
-	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
@@ -364,7 +362,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 			selectedModel := cfg.Models[agentCfg.Model]
 
 			// Anthropic models: thinking toggle
-			if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
+			if model.CanReason && len(model.ReasoningLevels) == 0 {
 				status := "Enable"
 				if selectedModel.Think {
 					status = "Disable"

internal/tui/components/dialogs/models/list.go ๐Ÿ”—

@@ -7,7 +7,7 @@ import (
 	"strings"
 
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"

internal/tui/components/dialogs/models/list_recent_test.go ๐Ÿ”—

@@ -9,7 +9,7 @@ import (
 	"testing"
 
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/tui/exp/list"

internal/tui/components/dialogs/models/models.go ๐Ÿ”—

@@ -9,8 +9,8 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/spinner"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/tui/components/core"

internal/tui/page/chat/chat.go ๐Ÿ”—

@@ -327,6 +327,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		u, cmd = p.chat.Update(msg)
 		p.chat = u.(chat.MessageListCmp)
 		cmds = append(cmds, cmd)
+		u, cmd = p.editor.Update(msg)
+		p.editor = u.(editor.Editor)
+		cmds = append(cmds, cmd)
 		return p, tea.Batch(cmds...)
 	case filepicker.FilePickedMsg,
 		completions.CompletionsClosedMsg,

internal/ui/chat/messages.go ๐Ÿ”—

@@ -7,8 +7,8 @@ import (
 	"time"
 
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/anim"

internal/ui/chat/tools.go ๐Ÿ”—

@@ -15,6 +15,7 @@ import (
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/stringext"
 	"github.com/charmbracelet/crush/internal/ui/anim"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
@@ -533,9 +534,7 @@ func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, n
 
 // toolOutputPlainContent renders plain text with optional expansion support.
 func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
-	content = strings.TrimSpace(content)
+	content = stringext.NormalizeSpace(content)
 	lines := strings.Split(content, "\n")
 
 	maxLines := responseContextHeight
@@ -568,8 +567,7 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan
 
 // toolOutputCodeContent renders code with syntax highlighting and line numbers.
 func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
+	content = stringext.NormalizeSpace(content)
 
 	lines := strings.Split(content, "\n")
 	maxLines := responseContextHeight
@@ -778,9 +776,7 @@ func roundedEnumerator(lPadding, width int) tree.Enumerator {
 
 // toolOutputMarkdownContent renders markdown content with optional truncation.
 func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
-	content = strings.TrimSpace(content)
+	content = stringext.NormalizeSpace(content)
 
 	// Cap width for readability.
 	if width > maxTextWidth {
@@ -1125,7 +1121,7 @@ func (t *baseToolMessageItem) formatViewResultForCopy() string {
 
 	var result strings.Builder
 	if lang != "" {
-		result.WriteString(fmt.Sprintf("```%s\n", lang))
+		fmt.Fprintf(&result, "```%s\n", lang)
 	} else {
 		result.WriteString("```\n")
 	}
@@ -1161,7 +1157,7 @@ func (t *baseToolMessageItem) formatEditResultForCopy() string {
 		}
 		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
 
-		result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
+		fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
 		result.WriteString("```diff\n")
 		result.WriteString(diffContent)
 		result.WriteString("\n```")
@@ -1195,7 +1191,7 @@ func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
 		}
 		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
 
-		result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
+		fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
 		result.WriteString("```diff\n")
 		result.WriteString(diffContent)
 		result.WriteString("\n```")
@@ -1253,9 +1249,9 @@ func (t *baseToolMessageItem) formatWriteResultForCopy() string {
 	}
 
 	var result strings.Builder
-	result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
+	fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
 	if lang != "" {
-		result.WriteString(fmt.Sprintf("```%s\n", lang))
+		fmt.Fprintf(&result, "```%s\n", lang)
 	} else {
 		result.WriteString("```\n")
 	}
@@ -1278,13 +1274,13 @@ func (t *baseToolMessageItem) formatFetchResultForCopy() string {
 
 	var result strings.Builder
 	if params.URL != "" {
-		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+		fmt.Fprintf(&result, "URL: %s\n", params.URL)
 	}
 	if params.Format != "" {
-		result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
+		fmt.Fprintf(&result, "Format: %s\n", params.Format)
 	}
 	if params.Timeout > 0 {
-		result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
+		fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
 	}
 	result.WriteString("\n")
 
@@ -1306,10 +1302,10 @@ func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
 
 	var result strings.Builder
 	if params.URL != "" {
-		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+		fmt.Fprintf(&result, "URL: %s\n", params.URL)
 	}
 	if params.Prompt != "" {
-		result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
+		fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
 	}
 
 	result.WriteString("```markdown\n")

internal/ui/dialog/actions.go ๐Ÿ”—

@@ -7,7 +7,7 @@ import (
 	"path/filepath"
 
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/message"

internal/ui/dialog/api_key_input.go ๐Ÿ”—

@@ -10,7 +10,7 @@ import (
 	"charm.land/bubbles/v2/spinner"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"

internal/ui/dialog/commands.go ๐Ÿ”—

@@ -9,8 +9,6 @@ import (
 	"charm.land/bubbles/v2/spinner"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
@@ -29,8 +27,9 @@ type CommandType uint
 func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
 
 const (
-	sidebarCompactModeBreakpoint  = 120
-	defaultCommandsDialogMaxWidth = 70
+	sidebarCompactModeBreakpoint   = 120
+	defaultCommandsDialogMaxHeight = 20
+	defaultCommandsDialogMaxWidth  = 70
 )
 
 const (
@@ -242,7 +241,7 @@ func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds boo
 func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	t := c.com.Styles
 	width := max(0, min(defaultCommandsDialogMaxWidth, area.Dx()))
-	height := max(0, min(defaultDialogHeight, area.Dy()))
+	height := max(0, min(defaultCommandsDialogMaxHeight, area.Dy()))
 	if area.Dx() != c.windowWidth && c.selected == SystemCommands {
 		c.windowWidth = area.Dx()
 		// since some items in the list depend on width (e.g. toggle sidebar command),
@@ -405,7 +404,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
 			selectedModel := cfg.Models[agentCfg.Model]
 
 			// Anthropic models: thinking toggle
-			if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
+			if model.CanReason && len(model.ReasoningLevels) == 0 {
 				status := "Enable"
 				if selectedModel.Think {
 					status = "Disable"

internal/ui/dialog/commands_item.go ๐Ÿ”—

@@ -66,11 +66,11 @@ func (c *CommandItem) Shortcut() string {
 
 // Render implements ListItem.
 func (c *CommandItem) Render(width int) string {
-	styles := ListIemStyles{
+	styles := ListItemStyles{
 		ItemBlurred:     c.t.Dialog.NormalItem,
 		ItemFocused:     c.t.Dialog.SelectedItem,
 		InfoTextBlurred: c.t.Base,
-		InfoTextFocused: c.t.Subtle,
+		InfoTextFocused: c.t.Base,
 	}
 	return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
 }

internal/ui/dialog/models.go ๐Ÿ”—

@@ -10,7 +10,7 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/uiutil"

internal/ui/dialog/models_item.go ๐Ÿ”—

@@ -1,8 +1,8 @@
 package dialog
 
 import (
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
@@ -106,11 +106,11 @@ func (m *ModelItem) Render(width int) string {
 	if m.showProvider {
 		providerInfo = string(m.prov.Name)
 	}
-	styles := ListIemStyles{
+	styles := ListItemStyles{
 		ItemBlurred:     m.t.Dialog.NormalItem,
 		ItemFocused:     m.t.Dialog.SelectedItem,
 		InfoTextBlurred: m.t.Base,
-		InfoTextFocused: m.t.Subtle,
+		InfoTextFocused: m.t.Base,
 	}
 	return renderItem(styles, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m)
 }

internal/ui/dialog/oauth.go ๐Ÿ”—

@@ -9,8 +9,8 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/spinner"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/ui/common"

internal/ui/dialog/oauth_copilot.go ๐Ÿ”—

@@ -6,7 +6,7 @@ import (
 	"time"
 
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/oauth/copilot"
 	"github.com/charmbracelet/crush/internal/ui/common"

internal/ui/dialog/oauth_hyper.go ๐Ÿ”—

@@ -6,7 +6,7 @@ import (
 	"time"
 
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/oauth/hyper"
 	"github.com/charmbracelet/crush/internal/ui/common"

internal/ui/dialog/reasoning.go ๐Ÿ”—

@@ -293,11 +293,11 @@ func (r *ReasoningItem) Render(width int) string {
 	if r.isCurrent {
 		info = "current"
 	}
-	styles := ListIemStyles{
+	styles := ListItemStyles{
 		ItemBlurred:     r.t.Dialog.NormalItem,
 		ItemFocused:     r.t.Dialog.SelectedItem,
 		InfoTextBlurred: r.t.Base,
-		InfoTextFocused: r.t.Subtle,
+		InfoTextFocused: r.t.Base,
 	}
 	return renderItem(styles, r.title, info, r.focused, width, r.cache, &r.m)
 }

internal/ui/dialog/sessions_item.go ๐Ÿ”—

@@ -76,7 +76,7 @@ func (s *SessionItem) Cursor() *tea.Cursor {
 // Render returns the string representation of the session item.
 func (s *SessionItem) Render(width int) string {
 	info := humanize.Time(time.Unix(s.UpdatedAt, 0))
-	styles := ListIemStyles{
+	styles := ListItemStyles{
 		ItemBlurred:     s.t.Dialog.NormalItem,
 		ItemFocused:     s.t.Dialog.SelectedItem,
 		InfoTextBlurred: s.t.Subtle,
@@ -101,14 +101,14 @@ func (s *SessionItem) Render(width int) string {
 	return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
 }
 
-type ListIemStyles struct {
+type ListItemStyles struct {
 	ItemBlurred     lipgloss.Style
 	ItemFocused     lipgloss.Style
 	InfoTextBlurred lipgloss.Style
 	InfoTextFocused lipgloss.Style
 }
 
-func renderItem(t ListIemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
+func renderItem(t ListItemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
 	if cache == nil {
 		cache = make(map[int]string)
 	}
@@ -141,14 +141,14 @@ func renderItem(t ListIemStyles, title string, info string, focused bool, width
 	titleWidth := lipgloss.Width(title)
 	gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
 	content := title
-	if matches := len(m.MatchedIndexes); matches > 0 {
+	if m != nil && len(m.MatchedIndexes) > 0 {
 		var lastPos int
 		parts := make([]string, 0)
 		ranges := matchedRanges(m.MatchedIndexes)
 		for _, rng := range ranges {
 			start, stop := bytePosToVisibleCharPos(title, rng)
 			if start > lastPos {
-				parts = append(parts, title[lastPos:start])
+				parts = append(parts, ansi.Cut(title, lastPos, start))
 			}
 			// NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
 			// because we can control the underline start and stop more
@@ -157,13 +157,13 @@ func renderItem(t ListIemStyles, title string, info string, focused bool, width
 			// with other style
 			parts = append(parts,
 				ansi.NewStyle().Underline(true).String(),
-				title[start:stop+1],
+				ansi.Cut(title, start, stop+1),
 				ansi.NewStyle().Underline(false).String(),
 			)
 			lastPos = stop + 1
 		}
-		if lastPos < len(title) {
-			parts = append(parts, title[lastPos:])
+		if lastPos < ansi.StringWidth(title) {
+			parts = append(parts, ansi.Cut(title, lastPos, ansi.StringWidth(title)))
 		}
 
 		content = strings.Join(parts, "")

internal/ui/image/image.go ๐Ÿ”—

@@ -168,7 +168,7 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
 				return chunk
 			},
 		}); err != nil {
-			slog.Error("failed to encode image for kitty graphics", "err", err)
+			slog.Error("Failed to encode image for kitty graphics", "err", err)
 			return uiutil.InfoMsg{
 				Type: uiutil.InfoTypeError,
 				Msg:  "failed to encode image",

internal/ui/list/highlight.go ๐Ÿ”—

@@ -5,6 +5,7 @@ import (
 	"strings"
 
 	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/stringext"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
@@ -53,6 +54,8 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin
 // HighlightBuffer highlights a region of text within the given content and
 // region, returning a [uv.ScreenBuffer].
 func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer {
+	content = stringext.NormalizeSpace(content)
+
 	if startLine < 0 || startCol < 0 {
 		return nil
 	}

internal/ui/list/list.go ๐Ÿ”—

@@ -77,6 +77,8 @@ func (l *List) Gap() int {
 
 // AtBottom returns whether the list is showing the last item at the bottom.
 func (l *List) AtBottom() bool {
+	const margin = 2
+
 	if len(l.items) == 0 {
 		return true
 	}
@@ -92,7 +94,7 @@ func (l *List) AtBottom() bool {
 		totalHeight += itemHeight
 	}
 
-	return totalHeight-l.offsetLine <= l.height
+	return totalHeight-l.offsetLine-margin <= l.height
 }
 
 // SetReverse shows the list in reverse order.

internal/ui/model/history.go ๐Ÿ”—

@@ -27,7 +27,7 @@ func (m *UI) loadPromptHistory() tea.Cmd {
 			messages, err = m.com.App.Messages.ListAllUserMessages(ctx)
 		}
 		if err != nil {
-			slog.Error("failed to load prompt history", "error", err)
+			slog.Error("Failed to load prompt history", "error", err)
 			return promptHistoryLoadedMsg{messages: nil}
 		}
 

internal/ui/model/keys.go ๐Ÿ”—

@@ -10,6 +10,7 @@ type KeyMap struct {
 		Newline     key.Binding
 		AddImage    key.Binding
 		MentionFile key.Binding
+		Commands    key.Binding
 
 		// Attachments key maps
 		AttachmentDeleteMode key.Binding
@@ -123,6 +124,10 @@ func DefaultKeyMap() KeyMap {
 		key.WithKeys("@"),
 		key.WithHelp("@", "mention file"),
 	)
+	km.Editor.Commands = key.NewBinding(
+		key.WithKeys("/"),
+		key.WithHelp("/", "commands"),
+	)
 	km.Editor.AttachmentDeleteMode = key.NewBinding(
 		key.WithKeys("ctrl+r"),
 		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),

internal/ui/model/sidebar.go ๐Ÿ”—

@@ -5,7 +5,6 @@ import (
 	"fmt"
 
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/logo"
 	uv "github.com/charmbracelet/ultraviolet"
@@ -28,14 +27,13 @@ func (m *UI) modelInfo(width int) string {
 
 			// Only check reasoning if model can reason
 			if model.CatwalkCfg.CanReason {
-				switch providerConfig.Type {
-				case catwalk.TypeAnthropic:
+				if model.ModelCfg.ReasoningEffort == "" {
 					if model.ModelCfg.Think {
 						reasoningInfo = "Thinking On"
 					} else {
 						reasoningInfo = "Thinking Off"
 					}
-				default:
+				} else {
 					formatter := cases.Title(language.English, cases.NoLower)
 					reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
 					reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))

internal/ui/model/ui.go ๐Ÿ”—

@@ -22,13 +22,12 @@ import (
 	"charm.land/bubbles/v2/spinner"
 	"charm.land/bubbles/v2/textarea"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/home"
@@ -118,6 +117,9 @@ type UI struct {
 	session      *session.Session
 	sessionFiles []SessionFile
 
+	// keeps track of read files while we don't have a session id
+	sessionFileReads []string
+
 	lastUserMessageTime int64
 
 	// The width and height of the terminal in cells.
@@ -142,7 +144,8 @@ type UI struct {
 
 	// sendProgressBar instructs the TUI to send progress bar updates to the
 	// terminal.
-	sendProgressBar bool
+	sendProgressBar    bool
+	progressBarEnabled bool
 
 	// caps hold different terminal capabilities that we query for.
 	caps common.Capabilities
@@ -293,6 +296,9 @@ func New(com *common.Common) *UI {
 	// set initial state
 	ui.setState(desiredState, desiredFocus)
 
+	// disable indeterminate progress bar
+	ui.progressBarEnabled = com.Config().Options.Progress == nil || *com.Config().Options.Progress
+
 	return ui
 }
 
@@ -324,7 +330,7 @@ func (m *UI) loadCustomCommands() tea.Cmd {
 	return func() tea.Msg {
 		customCommands, err := commands.LoadCustomCommands(m.com.Config())
 		if err != nil {
-			slog.Error("failed to load custom commands", "error", err)
+			slog.Error("Failed to load custom commands", "error", err)
 		}
 		return userCommandsLoadedMsg{Commands: customCommands}
 	}
@@ -335,7 +341,7 @@ func (m *UI) loadMCPrompts() tea.Cmd {
 	return func() tea.Msg {
 		prompts, err := commands.LoadMCPPrompts()
 		if err != nil {
-			slog.Error("failed to load mcp prompts", "error", err)
+			slog.Error("Failed to load MCP prompts", "error", err)
 		}
 		if prompts == nil {
 			// flag them as loaded even if there is none or an error
@@ -390,6 +396,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		// Reload prompt history for the new session.
 		m.historyReset()
 		cmds = append(cmds, m.loadPromptHistory())
+		m.updateLayoutAndSize()
 
 	case sendMessageMsg:
 		cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -527,13 +534,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.dialog.Update(msg)
 			return m, tea.Batch(cmds...)
 		}
+
+		if cmd := m.handleClickFocus(msg); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+
 		switch m.state {
 		case uiChat:
 			x, y := msg.X, msg.Y
 			// Adjust for chat area position
 			x -= m.layout.main.Min.X
 			y -= m.layout.main.Min.Y
-			if m.chat.HandleMouseDown(x, y) {
+			if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) && m.chat.HandleMouseDown(x, y) {
 				m.lastClickTime = time.Now()
 			}
 		}
@@ -681,7 +693,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case uv.KittyGraphicsEvent:
 		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
-			slog.Warn("unexpected Kitty graphics response",
+			slog.Warn("Unexpected Kitty graphics response",
 				"response", string(msg.Payload),
 				"options", msg.Options)
 		}
@@ -828,11 +840,14 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
 // if the message is a tool result it will update the corresponding tool call message
 func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 	var cmds []tea.Cmd
+	atBottom := m.chat.list.AtBottom()
+
 	existing := m.chat.MessageItem(msg.ID)
 	if existing != nil {
 		// message already exists, skip
 		return nil
 	}
+
 	switch msg.Role {
 	case message.User:
 		m.lastUserMessageTime = msg.CreatedAt
@@ -858,14 +873,18 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 			}
 		}
 		m.chat.AppendMessages(items...)
-		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
-			cmds = append(cmds, cmd)
+		if atBottom {
+			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
 		}
 		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
 			m.chat.AppendMessages(infoItem)
-			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
-				cmds = append(cmds, cmd)
+			if atBottom {
+				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			}
 		}
 	case message.Tool:
@@ -877,12 +896,35 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 			}
 			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
 				toolMsgItem.SetResult(&tr)
+				if atBottom {
+					if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
 			}
 		}
 	}
 	return tea.Batch(cmds...)
 }
 
+func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
+	switch {
+	case m.state != uiChat:
+		return nil
+	case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
+		return nil
+	case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
+		m.focus = uiFocusEditor
+		cmd = m.textarea.Focus()
+		m.chat.Blur()
+	case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
+		m.focus = uiFocusMain
+		m.textarea.Blur()
+		m.chat.Focus()
+	}
+	return cmd
+}
+
 // updateSessionMessage updates an existing message in the current session in the chat
 // when an assistant message is updated it may include updated tool calls as well
 // that is why we need to handle creating/updating each tool call message too
@@ -1393,14 +1435,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				return true
 			}
 		case key.Matches(msg, m.keyMap.Chat.PillLeft):
-			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
 				if cmd := m.switchPillSection(-1); cmd != nil {
 					cmds = append(cmds, cmd)
 				}
 				return true
 			}
 		case key.Matches(msg, m.keyMap.Chat.PillRight):
-			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
 				if cmd := m.switchPillSection(1); cmd != nil {
 					cmds = append(cmds, cmd)
 				}
@@ -1550,6 +1592,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				if cmd != nil {
 					cmds = append(cmds, cmd)
 				}
+			case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
+				if cmd := m.openCommandsDialog(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			default:
 				if handleGlobalKeys(msg) {
 					// Handle global keys first before passing to textarea.
@@ -1858,7 +1904,7 @@ func (m *UI) View() tea.View {
 	content = strings.Join(contentLines, "\n")
 
 	v.Content = content
-	if m.sendProgressBar && m.isAgentBusy() {
+	if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
 		// HACK: use a random percentage to prevent ghostty from hiding it
 		// after a timeout.
 		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
@@ -1873,7 +1919,7 @@ func (m *UI) ShortHelp() []key.Binding {
 	k := &m.keyMap
 	tab := k.Tab
 	commands := k.Commands
-	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
 		commands.SetHelp("/ or ctrl+p", "commands")
 	}
 
@@ -1949,7 +1995,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 	hasAttachments := len(m.attachments.List()) > 0
 	hasSession := m.hasSession()
 	commands := k.Commands
-	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
 		commands.SetHelp("/ or ctrl+p", "commands")
 	}
 
@@ -2424,21 +2470,27 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
 
 	return func() tea.Msg {
 		absPath, _ := filepath.Abs(path)
-		// Skip attachment if file was already read and hasn't been modified.
-		lastRead := filetracker.LastReadTime(absPath)
-		if !lastRead.IsZero() {
-			if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
-				return nil
+
+		if m.hasSession() {
+			// Skip attachment if file was already read and hasn't been modified.
+			lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
+			if !lastRead.IsZero() {
+				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
+					return nil
+				}
 			}
+		} else if slices.Contains(m.sessionFileReads, absPath) {
+			return nil
 		}
 
+		m.sessionFileReads = append(m.sessionFileReads, absPath)
+
 		// Add file as attachment.
 		content, err := os.ReadFile(path)
 		if err != nil {
 			// If it fails, let the LLM handle it later.
 			return nil
 		}
-		filetracker.RecordRead(absPath)
 
 		return message.Attachment{
 			FilePath: path,
@@ -2565,6 +2617,10 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
 		m.setState(uiChat, m.focus)
 	}
 
+	for _, path := range m.sessionFileReads {
+		m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path)
+	}
+
 	// Capture session ID to avoid race with main goroutine updating m.session.
 	sessionID := m.session.ID
 	cmds = append(cmds, func() tea.Msg {
@@ -2811,6 +2867,7 @@ func (m *UI) newSession() tea.Cmd {
 
 	m.session = nil
 	m.sessionFiles = nil
+	m.sessionFileReads = nil
 	m.setState(uiLanding, uiFocusEditor)
 	m.textarea.Focus()
 	m.chat.Blur()

internal/ui/styles/styles.go ๐Ÿ”—

@@ -2,6 +2,7 @@ package styles
 
 import (
 	"image/color"
+	"strings"
 
 	"charm.land/bubbles/v2/filepicker"
 	"charm.land/bubbles/v2/help"
@@ -1355,35 +1356,36 @@ func boolPtr(b bool) *bool       { return &b }
 func stringPtr(s string) *string { return &s }
 func uintPtr(u uint) *uint       { return &u }
 func chromaStyle(style ansi.StylePrimitive) string {
-	var s string
+	var s strings.Builder
 
 	if style.Color != nil {
-		s = *style.Color
+		s.WriteString(*style.Color)
 	}
 	if style.BackgroundColor != nil {
-		if s != "" {
-			s += " "
+		if s.Len() > 0 {
+			s.WriteString(" ")
 		}
-		s += "bg:" + *style.BackgroundColor
+		s.WriteString("bg:")
+		s.WriteString(*style.BackgroundColor)
 	}
 	if style.Italic != nil && *style.Italic {
-		if s != "" {
-			s += " "
+		if s.Len() > 0 {
+			s.WriteString(" ")
 		}
-		s += "italic"
+		s.WriteString("italic")
 	}
 	if style.Bold != nil && *style.Bold {
-		if s != "" {
-			s += " "
+		if s.Len() > 0 {
+			s.WriteString(" ")
 		}
-		s += "bold"
+		s.WriteString("bold")
 	}
 	if style.Underline != nil && *style.Underline {
-		if s != "" {
-			s += " "
+		if s.Len() > 0 {
+			s.WriteString(" ")
 		}
-		s += "underline"
+		s.WriteString("underline")
 	}
 
-	return s
+	return s.String()
 }

schema.json ๐Ÿ”—

@@ -432,7 +432,13 @@
         },
         "auto_lsp": {
           "type": "boolean",
-          "description": "Automatically setup LSPs based on root markers"
+          "description": "Automatically setup LSPs based on root markers",
+          "default": true
+        },
+        "progress": {
+          "type": "boolean",
+          "description": "Show indeterminate progress updates during long operations",
+          "default": true
         }
       },
       "additionalProperties": false,

scripts/check_log_capitalization.sh ๐Ÿ”—

@@ -0,0 +1,5 @@
+#!/bin/bash
+if grep -rE 'slog\.(Error|Info|Warn|Debug|Fatal|Print|Println|Printf)\(["\"][a-z]' --include="*.go" . 2>/dev/null; then
+  echo "โŒ Log messages must start with a capital letter. Found lowercase logs above."
+  exit 1
+fi