diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json
index 5b5e74252b831d49bdec16557311a8e39de71b16..5c18e45ad2a8120191a89d58eb101f84303902b0 100644
--- a/.github/cla-signatures.json
+++ b/.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
}
]
}
\ No newline at end of file
diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml
index 0c3d5ce6d437a39471003018545d8546fa220ef6..a5a45d8fdeeaf8f0c1374366e7c1d34839c1acc5 100644
--- a/.github/workflows/snapshot.yml
+++ b/.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:
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 784201677ed863e460818d98ac54e651bbfb7fee..0ba2b1eccdf6de70c3e39d9111074a84658bd2a3 100644
--- a/.goreleaser.yml
+++ b/.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
diff --git a/AGENTS.md b/AGENTS.md
index 7fab72afb836136020500b7f27e905f3dcfc72da..654f1cd0a7fe1cbb50a3026f86f31b68e04f8043 100644
--- a/AGENTS.md
+++ b/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
diff --git a/README.md b/README.md
index cd68cb962de3518cce6f86ac3513d388bf9bfcd0..6e167345dd92ffb7a4d56241e9da7258a7c89b97 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
Your new coding bestie, now available in your favourite terminal.
Your tools, your code, and your workflows, wired into your LLM of choice.
-你的新编程伙伴,现在就在你最爱的终端中。
你的工具、代码和工作流,都与您选择的 LLM 模型紧密相连。
+终端里的编程新搭档,
无缝接入你的工具、代码与工作流,全面兼容主流 LLM 模型。

diff --git a/Taskfile.yaml b/Taskfile.yaml
index 9ffe8923d6bbd92caf441d872726de48352b2faa..bff27387d6be353ccd02cf6437b4acafb30334c9 100644
--- a/Taskfile.yaml
+++ b/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
diff --git a/go.mod b/go.mod
index 9281f1b44966d5ce00d19264b6ad29dfc4cb4aa4..4ea501fd125ce2a8ef62b4555229218b5d65ff19 100644
--- a/go.mod
+++ b/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
diff --git a/go.sum b/go.sum
index c0d8fdcc25091a334f0f79fcf2e5f91247496fdc..af4bd1eaab7bb4696ef0d344113a435f90a7a4ac 100644
--- a/go.sum
+++ b/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=
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index c3f8e38edc87aca706741f27660b7d989d0e6e45..e76643470c2252f5474f05af7344bcc3b975bf46 100644
--- a/internal/agent/agent.go
+++ b/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\n" + s + "\n"
+ }
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
}
diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go
index 89d3535720f8452111f12f4df4eb691e39253bed..08da0e870187f537c9c88ac6a2b6ada97ff6fc88 100644
--- a/internal/agent/agentic_fetch_tool.go
+++ b/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{
diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go
index 3f4e8daddbd4de34e788bce59a9573c00d940252..4f96c3cfbb1728f533c71a7c05b7e1ab85975b45 100644
--- a/internal/agent/common_test.go
+++ b/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
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index e840f73b1e32d943c997dab3d95bcd4a6240c14a..6c726c241ba27927200a31d6cc93e90c3f063c31 100644
--- a/internal/agent/coordinator.go
+++ b/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
}
diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go
index 03278ae99f87608c65263b0ffef7fb473cd58e31..8ba3a538e4a97b4691dff4eb9aba46f83b523912 100644
--- a/internal/agent/hyper/provider.go
+++ b/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
})
diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go
index 2c9b15abfe148fb881ee90f75f207c1134776281..74b84c784796a97db2f379cf61fb3eb8b18934d4 100644
--- a/internal/agent/tools/edit.go
+++ b/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),
diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go
index c9920d8bce024253cfb52527b21ac748dc5d0bff..f980c6e9a9147ab25b2dd7f3a1a839198c399432 100644
--- a/internal/agent/tools/mcp/init.go
+++ b/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
}
diff --git a/internal/agent/tools/mcp/prompts.go b/internal/agent/tools/mcp/prompts.go
index 0bd6e665dd80dad90c844d7d31c61c506ea83803..ea208a57716d2a273fde1b6faa3988ca2e57b012 100644
--- a/internal/agent/tools/mcp/prompts.go
+++ b/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
}
diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go
index 1329a34e25df3545e8620399522d6db4bd0244b7..0857b10de80aee5fe71b85aea6c02f6db2c4aaf5 100644
--- a/internal/agent/tools/mcp/tools.go
+++ b/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
}
diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go
index 0640228d23230e6a49d8e1405f371c099031fbf7..48736ebf311230a28b51702e0ddd3ff8df19b284 100644
--- a/internal/agent/tools/multiedit.go
+++ b/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 {
diff --git a/internal/agent/tools/multiedit_test.go b/internal/agent/tools/multiedit_test.go
index b6d575435e63dcd62a4dc9a7efb76cf13c14ad05..1ca2a6f7689e345ac944889f1f92284de0652f90 100644
--- a/internal/agent/tools/multiedit_test.go
+++ b/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
diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go
index 35865cf43f7c587d60764b3ed177374940bbe2dc..b26267fcef3b296babc3c9dbcee64336ef162b75 100644
--- a/internal/agent/tools/view.go
+++ b/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\n"
output += getDiagnostics(filePath, lspClients)
- filetracker.RecordRead(filePath)
+ filetracker.RecordRead(ctx, sessionID, filePath)
return fantasy.WithResponseMetadata(
fantasy.NewTextResponse(output),
ViewResponseMetadata{
diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go
index 8becaea3c08157897dcece7b3d5d4de5cb2ee929..c2f5c7d1c83efd0731e8623c1e9cbb98b9bfdd2f 100644
--- a/internal/agent/tools/write.go
+++ b/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)
diff --git a/internal/app/app.go b/internal/app/app.go
index ef6e636e44eeea9407557ca48f8ba9bd8eba72b2..c5294c2ae21f91486861a037b639cb1c00bd531f 100644
--- a/internal/app/app.go
+++ b/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.
diff --git a/internal/app/lsp.go b/internal/app/lsp.go
index 39e03d3cb4f2f5a9dc7720f8ce1f7286d4efd6b2..14f1c99587bf4bfe052f9ac2078cdf03d859cfa1 100644
--- a/internal/app/lsp.go
+++ b/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)
diff --git a/internal/app/provider_test.go b/internal/app/provider_test.go
index c3acae64d1057f3bb8bd8f9a0cb6443dbe9731b7..8430211e0067810523a713a07a343ac546248830 100644
--- a/internal/app/provider_test.go
+++ b/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"
)
diff --git a/internal/cmd/models.go b/internal/cmd/models.go
index 3267469638ee83463e1785774d37c5d281d37de9..e2aa5c991d5cf49ba78dbff9d3f79c4f6493523d 100644
--- a/internal/cmd/models.go
+++ b/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"
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 577d4ccb4abaa79275a5a556c463cb52b16aab11..b33303d1bbabb408988d50378ea2370896fb929b 100644
--- a/internal/cmd/root.go
+++ b/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.
diff --git a/internal/cmd/run.go b/internal/cmd/run.go
index e4d72b41be13684e28ca6c2b85b79bfdcea52fc7..50005a548bad0308bdca3a2afbe17503c1f86c56 100644
--- a/internal/cmd/run.go
+++ b/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")
}
diff --git a/internal/cmd/stats/index.css b/internal/cmd/stats/index.css
index b01c84442f6cbe1675f46ec02a65d801d0abed2d..0216f9f79bd6bd16f77a5fd0ec14e9c142815436 100644
--- a/internal/cmd/stats/index.css
+++ b/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 {
diff --git a/internal/config/catwalk.go b/internal/config/catwalk.go
index c3cc2eb69d47e1a85e35164fda09d0f73761b820..0c12c899c7ee34d6515410cccab13ac850a361a7 100644
--- a/internal/config/catwalk.go
+++ b/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 {
diff --git a/internal/config/catwalk_test.go b/internal/config/catwalk_test.go
index 55322b34eb7252f8cae75fb46996f45bd31abe5e..df6aea475811adfe3e4fb8935185842c7c81d145 100644
--- a/internal/config/catwalk_test.go
+++ b/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"
)
diff --git a/internal/config/config.go b/internal/config/config.go
index d18d2d9c61d2f791ab9c6f9a0b7cd41029b70e60..19133928bd8f7e1da08b54024b4f80d41d01dc1a 100644
--- a/internal/config/config.go
+++ b/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
}
}
diff --git a/internal/config/copilot.go b/internal/config/copilot.go
index ee50bec43d6ce5754799adf4bfe99ba9b357d690..d72e7d5048ba4d31c88d7f7152a6b3a9510960a2 100644
--- a/internal/config/copilot.go
+++ b/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"
)
diff --git a/internal/config/docker_mcp_test.go b/internal/config/docker_mcp_test.go
index 59e42e172303c845d0df462eac15480cb09810de..b1fb3c98f262ecefa2ece2ab192e1455452611a3 100644
--- a/internal/config/docker_mcp_test.go
+++ b/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"
)
diff --git a/internal/config/hyper.go b/internal/config/hyper.go
index 5fe6fc5a1ee54bd19902ef4c9cc6034a6b294b6f..6772f27b3bd3be136d001139a8505a7bb3fedef3 100644
--- a/internal/config/hyper.go
+++ b/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"
)
diff --git a/internal/config/hyper_test.go b/internal/config/hyper_test.go
index 7141eaa1e97888b5ee6f84afc8e9658825547b46..e4b6ac8acdfcdeb19f0d600baf88a337d40c230d 100644
--- a/internal/config/hyper_test.go
+++ b/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"
)
diff --git a/internal/config/load.go b/internal/config/load.go
index 25139cb5f4b2ba8013525bfde025f04cb267d1b8..3ad4b909cb16cf5672dcadc9322a476854350632 100644
--- a/internal/config/load.go
+++ b/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"
diff --git a/internal/config/load_test.go b/internal/config/load_test.go
index 08c888318724104935b9e92403f09f54f8ae20a4..60a0b7379501a7d766b33c4828c644cdb390bada 100644
--- a/internal/config/load_test.go
+++ b/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"
diff --git a/internal/config/provider.go b/internal/config/provider.go
index 253d6f658a567ed5302887ecb87415de0a89c504..6ca981e5a73cbf3e3472b05f55c7b911a4a857c3 100644
--- a/internal/config/provider.go
+++ b/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"
diff --git a/internal/config/provider_empty_test.go b/internal/config/provider_empty_test.go
index 7c37a9afb9694f0ea4352faee1b11d7e40d9480e..9bc62f5c3141d239aaadc3947dce539a4dcf4810 100644
--- a/internal/config/provider_empty_test.go
+++ b/internal/config/provider_empty_test.go
@@ -5,7 +5,7 @@ import (
"os"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/stretchr/testify/require"
)
diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go
index e8790e286c3ffc8db77edb0ef8353e54ad519458..283c18c8ab68c013dadf6f4fc8174f4947210f3a 100644
--- a/internal/config/provider_test.go
+++ b/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"
)
diff --git a/internal/db/db.go b/internal/db/db.go
index a4e430c720f33f4cd3c0b9710633595ef5c5fa1f..739c2087e1c1e125875d5006c86f85de37fed3be 100644
--- a/internal/db/db.go
+++ b/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,
diff --git a/internal/db/migrations/20260127000000_add_read_files_table.sql b/internal/db/migrations/20260127000000_add_read_files_table.sql
new file mode 100644
index 0000000000000000000000000000000000000000..1161f1992885fc66e309024a0d874565ea276229
--- /dev/null
+++ b/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
diff --git a/internal/db/models.go b/internal/db/models.go
index 317e7c92e09c857ee610832e365af2c4ecc90181..a105074ab9e6320bd92b90121e7694b1f8cd1e5a 100644
--- a/internal/db/models.go
+++ b/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"`
diff --git a/internal/db/querier.go b/internal/db/querier.go
index 394ba1f71aea47c93956e91fcaf07e02f65098b8..c233fd59f63f8b46d3e6d62e1c162f47d6d34e3f 100644
--- a/internal/db/querier.go
+++ b/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
diff --git a/internal/db/read_files.sql.go b/internal/db/read_files.sql.go
new file mode 100644
index 0000000000000000000000000000000000000000..b18907c1f27a3c753b6b1a2cf1ca0563c3fd78d5
--- /dev/null
+++ b/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
+}
diff --git a/internal/db/sql/read_files.sql b/internal/db/sql/read_files.sql
new file mode 100644
index 0000000000000000000000000000000000000000..f607312c2ba8660aa2c7030e415ce2ca7320cd6d
--- /dev/null
+++ b/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;
diff --git a/internal/event/event.go b/internal/event/event.go
index 674586b06bee03f22c1bd880a5bd39b740c75f66..10b054ce0b21fb3c0db441746827a20739963315 100644
--- a/internal/event/event.go
+++ b/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
}
}
diff --git a/internal/event/event_test.go b/internal/event/event_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..7cd22248f19ca072853cd4270ae6fc36e4c124f5
--- /dev/null
+++ b/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)
+}
diff --git a/internal/filetracker/filetracker.go b/internal/filetracker/filetracker.go
deleted file mode 100644
index 534a19dacdc209f7ef2d9c5b107cb5f88a665ee5..0000000000000000000000000000000000000000
--- a/internal/filetracker/filetracker.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/filetracker/service.go b/internal/filetracker/service.go
new file mode 100644
index 0000000000000000000000000000000000000000..8f080d124e49dfc32f43796194c09ac22beaa9f1
--- /dev/null
+++ b/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
+}
diff --git a/internal/filetracker/service_test.go b/internal/filetracker/service_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..c7fb15090dd31e9591c5c3b9c2a256c839aea3f6
--- /dev/null
+++ b/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")
+}
diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go
index c22b960ad02a42bf6adac7768b7d99e55a9390ee..b541a4a0fedd78c866fa274fc183fabe4c833edd 100644
--- a/internal/fsext/ls.go
+++ b/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,
diff --git a/internal/home/home.go b/internal/home/home.go
index e44649235ff5bb24c8bb644ae90e9002add45237..80fb1ea2e01630597c2547eaa8e4e55150ec6976 100644
--- a/internal/home/home.go
+++ b/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)
}
}
diff --git a/internal/lsp/client.go b/internal/lsp/client.go
index 98aa75966160ba97af8c431d98c642fb558e5dc7..05ee570b9d5ad7a0d667b48084289bf0fe5d3dde 100644
--- a/internal/lsp/client.go
+++ b/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
}
diff --git a/internal/message/content.go b/internal/message/content.go
index 3fed1f06019c855d30af9d5583e6a7b63fcbd508..02f949334b688e4dd40c832d5f68d52523ac9953 100644
--- a/internal/message/content.go
+++ b/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
diff --git a/internal/session/session.go b/internal/session/session.go
index 905ee1cf1417b148019d9688985c1f5200209d69..0ef6cfe22bebbf35df48f0db1fbe00c6d128251b 100644
--- a/internal/session/session.go
+++ b/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,
diff --git a/internal/stringext/string.go b/internal/stringext/string.go
index 03456db93bc148f7c77e52da3c493c94fa79624f..8be28ccc2096c3d54b9f3106ed30d584503acdf4 100644
--- a/internal/stringext/string.go
+++ b/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
+}
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index ba832b415133305fccbefa37da6b749405feb2c6..575c23114a9115209db7a2a02e642fe5f2246541 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/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
}
diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go
index b4db149946fe0a1f67c957eeb04da2966e1f5f28..3c91f9f41485b439b8c25ca0692c7265ccafb14a 100644
--- a/internal/tui/components/chat/messages/messages.go
+++ b/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"
diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go
index a454605c8ee2938fa02d98d9770704388d0bd38a..40bc8821e0a3dc7c3dec62bbcde34a5241ec4aa7 100644
--- a/internal/tui/components/chat/sidebar/sidebar.go
+++ b/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
diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go
index 517f6d0930c46cf3d2e9f656c22515de4e9785fd..886fe5e530978678246ab120b21e0f943018fd1a 100644
--- a/internal/tui/components/chat/splash/splash.go
+++ b/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"
diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index cde5b203ca985f81c390d02725ef04d11a5cd518..3c86c984561f96350b2b621c15ae14be9649ae36 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/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"
diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go
index 581122525a89dd308bb57a30e6b15a4cd0896708..50469a132aab60c3e63a77d9169c47688d5d9151 100644
--- a/internal/tui/components/dialogs/models/list.go
+++ b/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"
diff --git a/internal/tui/components/dialogs/models/list_recent_test.go b/internal/tui/components/dialogs/models/list_recent_test.go
index 9b738a4b17fbaa2de18de080a769cce41a676007..5afdde98502d3d26d46dce00ab1825ca07f36831 100644
--- a/internal/tui/components/dialogs/models/list_recent_test.go
+++ b/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"
diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go
index b06b4b475a9ababbda9e0702fc5552b0959741ba..34f91d060cf7b7a7fd0a3a6fe678a23ed8439530 100644
--- a/internal/tui/components/dialogs/models/models.go
+++ b/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"
diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go
index 9a4b69f5507fbb62b7ee93df6326f94cf79d22ad..bb2eb755bf80995dd41d9ac564174de5b90262bb 100644
--- a/internal/tui/page/chat/chat.go
+++ b/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,
diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go
index 45314347187e7018445d30753ffd05d24dbc716a..ad8aad399cf40809f16779dd277536d9ad47d5e3 100644
--- a/internal/ui/chat/messages.go
+++ b/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"
diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go
index 0aee7fb93636d88da3489b4dece92ccbf46674d7..d33c3b83ecf4aade6ff8ebfd14419665f3fd8b85 100644
--- a/internal/ui/chat/tools.go
+++ b/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")
diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go
index a2ea1813a9d9545f4ea6f61931fcb36320067527..877656688540207d7a95a071d99aac83819a2cab 100644
--- a/internal/ui/dialog/actions.go
+++ b/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"
diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go
index 65fe5cfb9cb14eb60f4399b0477d6cd071315750..0ca50b8fe7f8899f16aac8428caa796c5da89610 100644
--- a/internal/ui/dialog/api_key_input.go
+++ b/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"
diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go
index 1f20d3c8869eb4b67939be139937d2905dfa2b44..c56da4aa84e1d2220705a5ace02ac2ad6b1af8dd 100644
--- a/internal/ui/dialog/commands.go
+++ b/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"
diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go
index b1977545ded8e8eeb8fc1e59c5a0a31e18ce8610..89cd552f8ef5acfb326e9fbcae87b0a542b35022 100644
--- a/internal/ui/dialog/commands_item.go
+++ b/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)
}
diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go
index 450ee8b99b75f13c1c9885281a1dfd1a0a3d9867..354d02434a6623b5a9833bc010f4eaa8d1efdc7a 100644
--- a/internal/ui/dialog/models.go
+++ b/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"
diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go
index bfe30c0e3a04c24c71579bfbdbd06b576e1ad033..645b26e987b38baabd27338d43a19a4652144788 100644
--- a/internal/ui/dialog/models_item.go
+++ b/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)
}
diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go
index e4f7a521cacb51d215ca405883351558ed7179d6..6fbb039255144ad14b15a39f34942e504dea3f2c 100644
--- a/internal/ui/dialog/oauth.go
+++ b/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"
diff --git a/internal/ui/dialog/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go
index 4b671852d476578f94653393796056d630ba23a5..8afb0df23134bb9e820ae2385d6b9b6838e07d98 100644
--- a/internal/ui/dialog/oauth_copilot.go
+++ b/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"
diff --git a/internal/ui/dialog/oauth_hyper.go b/internal/ui/dialog/oauth_hyper.go
index bddf4d78ef2c920855f21e056e7ee48f985b0b68..d90c385db782478721fd3e9efa49a2984f34304d 100644
--- a/internal/ui/dialog/oauth_hyper.go
+++ b/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"
diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go
index 4c5dad086bb01eb3dc12f2f6d379c87a5638d297..2a333f155cdc1499993f05411d7090793f74f54e 100644
--- a/internal/ui/dialog/reasoning.go
+++ b/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)
}
diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go
index 87a2627daa3b63eca309feeb914ec80c33e2ef1f..f4e7f061a83ec171940c02832d3b1bfe4d5b7ef7 100644
--- a/internal/ui/dialog/sessions_item.go
+++ b/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, "")
diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go
index 5644146fec5b1e4e1e3a96c92a315c0bf986180d..07039433dded1647646704959791dfcad7d3d69f 100644
--- a/internal/ui/image/image.go
+++ b/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",
diff --git a/internal/ui/list/highlight.go b/internal/ui/list/highlight.go
index fefe836d110b52496028d21071fffc5262189d92..631181db29ce5bc3a2087de30341342f0374b229 100644
--- a/internal/ui/list/highlight.go
+++ b/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
}
diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go
index 0883ab2b56c5bb7ab26073301890c832e7c4e441..33a5087c9ceae3f03bb2c8f78b2cc8089f87057c 100644
--- a/internal/ui/list/list.go
+++ b/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.
diff --git a/internal/ui/model/history.go b/internal/ui/model/history.go
index 5acc6ef5feabdab2bcb7a81ba8a60f5f224dab11..5d2284ab1756257cc06b76de4621849f1e3071ba 100644
--- a/internal/ui/model/history.go
+++ b/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}
}
diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go
index cf2fdcaa431b2a9c43a9612ef99ec8ce696216ca..a42b1e7aa0ac9ac474de626b55ceb3a91824cdff 100644
--- a/internal/ui/model/keys.go
+++ b/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"),
diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go
index 7e6a61864a42f37ba7bf1c955b6844f4c488b942..7316025aaedad67688b226cf1c7c37314f3b7a30 100644
--- a/internal/ui/model/sidebar.go
+++ b/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))
diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go
index 48d90512674590d42283749d89bf77e7febbb3e0..06520d35d2bb2b598daaa1efff3ac81dd73765a8 100644
--- a/internal/ui/model/ui.go
+++ b/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()
diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go
index d07b2e4bc2983dde5fa79fd66d27cc31aae20614..726d62c97b8f509822c7f99af02c62e4705ac677 100644
--- a/internal/ui/styles/styles.go
+++ b/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()
}
diff --git a/schema.json b/schema.json
index 47b19589f29cdf6165f0b5c93a97168e3396e6bd..7a32f612e64a20d0393f74471c1fbdb8863c2365 100644
--- a/schema.json
+++ b/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,
diff --git a/scripts/check_log_capitalization.sh b/scripts/check_log_capitalization.sh
new file mode 100755
index 0000000000000000000000000000000000000000..fa5f651dfb1a7dc53876018029599edd3479d94f
--- /dev/null
+++ b/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