Detailed changes
@@ -663,6 +663,94 @@
"created_at": "2025-09-26T13:30:16Z",
"repoId": 987670088,
"pullRequestNo": 1135
+ },
+ {
+ "name": "maxious",
+ "id": 81432,
+ "comment_id": 3341700737,
+ "created_at": "2025-09-27T13:09:22Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1141
+ },
+ {
+ "name": "Wangch29",
+ "id": 115294077,
+ "comment_id": 3344526018,
+ "created_at": "2025-09-29T01:19:40Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1148
+ },
+ {
+ "name": "kucukkanat",
+ "id": 914316,
+ "comment_id": 3369230313,
+ "created_at": "2025-10-05T18:13:57Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1195
+ },
+ {
+ "name": "thuggys",
+ "id": 150315417,
+ "comment_id": 3369149503,
+ "created_at": "2025-10-05T15:59:55Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1194
+ },
+ {
+ "name": "nikaro",
+ "id": 3918653,
+ "comment_id": 3373586148,
+ "created_at": "2025-10-06T19:31:50Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1200
+ },
+ {
+ "name": "daps94",
+ "id": 35882689,
+ "comment_id": 3395964275,
+ "created_at": "2025-10-13T05:56:20Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1223
+ },
+ {
+ "name": "BrunoKrugel",
+ "id": 30608179,
+ "comment_id": 3411978929,
+ "created_at": "2025-10-16T17:30:07Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1245
+ },
+ {
+ "name": "dpolishuk",
+ "id": 466424,
+ "comment_id": 3418756045,
+ "created_at": "2025-10-18T19:24:00Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1254
+ },
+ {
+ "name": "Jesssullivan",
+ "id": 37297218,
+ "comment_id": 3439361465,
+ "created_at": "2025-10-23T21:50:17Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1292
+ },
+ {
+ "name": "mmangkad",
+ "id": 176301910,
+ "comment_id": 3440286180,
+ "created_at": "2025-10-24T01:32:47Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1294
+ },
+ {
+ "name": "blouflab",
+ "id": 227565774,
+ "comment_id": 3444483981,
+ "created_at": "2025-10-24T18:59:01Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1296
}
]
}
@@ -17,7 +17,7 @@ jobs:
with:
go-version-file: go.mod
- run: go run . schema > ./schema.json
- - uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 # v5
+ - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5
with:
commit_message: "chore: auto-update generated files"
branch: main
@@ -303,6 +303,7 @@ changelog:
- "^docs: update$"
- "^test:"
- "^test\\("
+ - "^v\\d.*"
- "merge conflict"
- "merge conflict"
- Merge branch
@@ -312,19 +313,19 @@ changelog:
- "^wip "
- "^wip:"
groups:
- - title: "New Features"
- regexp: '^.*?feat(\(.+\))??!?:.+$'
+ - title: "Deps"
+ regexp: "^.*\\(deps\\)*:+.*$"
+ order: 300
+ - title: "New!"
+ regexp: "^.*feat[(\\w)]*:+.*$"
order: 100
- - title: "Security updates"
- regexp: '^.*?sec(\(.+\))??!?:.+$'
- order: 150
- - title: "Bug fixes and improvements"
- regexp: '^.*?(fix|refactor)(\(.+\))??!?:.+$'
+ - title: "Fixed"
+ regexp: "^.*fix[(\\w)]*:+.*$"
order: 200
- - title: "Documentation updates"
- regexp: ^.*?docs?(\(.+\))??!?:.+$
+ - title: "Docs"
+ regexp: "^.*docs[(\\w)]*:+.*$"
order: 400
- - title: Other work
+ - title: "Other stuff"
order: 9999
release:
@@ -54,7 +54,7 @@ func TestYourFunction(t *testing.T) {
## Formatting
- ALWAYS format any Go code you write.
- - First, try `goftumpt -w .`.
+ - First, try `gofumpt -w .`.
- If `gofumpt` is not available, use `goimports`.
- If `goimports` is not available, use `gofmt`.
- You can also use `task fmt` to run `gofumpt -w .` on the entire project,
@@ -189,8 +189,8 @@ That said, you can also set environment variables for preferred providers.
| `AWS_ACCESS_KEY_ID` | AWS Bedrock (Claude) |
| `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (Claude) |
| `AWS_REGION` | AWS Bedrock (Claude) |
-| `AWS_PROFILE` | Custom AWS Profile |
-| `AWS_REGION` | AWS Region |
+| `AWS_PROFILE` | AWS Bedrock (Custom Profile) |
+| `AWS_BEARER_TOKEN_BEDROCK` | AWS Bedrock |
| `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models |
| `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) |
| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models |
@@ -479,6 +479,7 @@ Crush currently supports running Anthropic models through Bedrock, with caching
- A Bedrock provider will appear once you have AWS configured, i.e. `aws configure`
- Crush also expects the `AWS_REGION` or `AWS_DEFAULT_REGION` to be set
- To use a specific AWS profile set `AWS_PROFILE` in your environment, i.e. `AWS_PROFILE=myprofile crush`
+- Alternatively to `aws configure`, you can also just set `AWS_BEARER_TOKEN_BEDROCK`
### Vertex AI Platform
@@ -649,8 +650,8 @@ See the [contributing guide](https://github.com/charmbracelet/crush?tab=contribu
We’d love to hear your thoughts on this project. Need help? We gotchu. You can find us on:
- [Twitter](https://twitter.com/charmcli)
-- [Discord][discord]
- [Slack](https://charm.land/slack)
+- [Discord][discord]
- [The Fediverse](https://mastodon.social/@charmcli)
- [Bluesky](https://bsky.app/profile/charm.land)
@@ -2,6 +2,10 @@
version: "3"
+vars:
+ VERSION:
+ sh: git describe --long 2>/dev/null || echo ""
+
env:
CGO_ENABLED: 0
GOEXPERIMENT: greenteagc
@@ -30,15 +34,17 @@ tasks:
build:
desc: Run build
+ vars:
+ LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
cmds:
- - go build .
+ - go build {{.LDFLAGS}} .
generates:
- crush
run:
desc: Run build
cmds:
- - go run .
+ - go run . {{.CLI_ARGS}}
test:
desc: Run tests
@@ -59,8 +65,10 @@ tasks:
install:
desc: Install the application
+ vars:
+ LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
cmds:
- - go install -v .
+ - go install {{.LDFLAGS}} -v .
profile:cpu:
desc: 10s CPU profile
@@ -89,7 +97,7 @@ tasks:
desc: Create and push a new tag following semver
vars:
NEXT:
- sh: go run github.com/caarlos0/svu/v3@latest next --always
+ sh: svu next --always || go run github.com/caarlos0/svu/v3@latest next --always
prompt: "This will release {{.NEXT}}. Continue?"
preconditions:
- sh: '[ $(git symbolic-ref --short HEAD) = "main" ]'
@@ -97,7 +105,13 @@ tasks:
- sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]"
msg: "Git is dirty"
cmds:
- - git tag -d nightly
- - git tag --sign {{.NEXT}} {{.CLI_ARGS}}
- - echo "pushing {{.NEXT}}..."
- - git push origin --tags
+ - task: fetch-tags
+ - git commit --allow-empty -m "{{.NEXT}}"
+ - git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}}
+ - echo "Pushing {{.NEXT}}..."
+ - git push origin main --follow-tags
+
+ fetch-tags:
+ cmds:
+ - git tag -d nightly || true
+ - git fetch --tags
@@ -7,32 +7,33 @@ require (
github.com/MakeNowJust/heredoc v1.0.0
github.com/PuerkitoBio/goquery v1.10.3
github.com/alecthomas/chroma/v2 v2.20.0
- github.com/anthropics/anthropic-sdk-go v1.12.0
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.3.1
github.com/bmatcuk/doublestar/v4 v4.9.1
github.com/charlievieth/fastwalk v1.0.14
+ github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2
- github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250910155747-997384b0b35e
- github.com/charmbracelet/catwalk v0.6.1
- github.com/charmbracelet/fang v0.4.2
+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5
+ github.com/charmbracelet/catwalk v0.7.0
+ github.com/charmbracelet/fang v0.4.3
github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea
github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706
- github.com/charmbracelet/x/ansi v0.10.1
+ github.com/charmbracelet/x/ansi v0.10.2
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
+ github.com/charmbracelet/x/exp/ordered v0.1.0
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/google/uuid v1.6.0
github.com/invopop/jsonschema v0.13.0
github.com/joho/godotenv v1.5.1
- github.com/mark3labs/mcp-go v0.40.0
+ github.com/modelcontextprotocol/go-sdk v1.0.0
github.com/muesli/termenv v0.16.0
- github.com/ncruces/go-sqlite3 v0.29.0
+ github.com/ncruces/go-sqlite3 v0.29.1
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/nxadm/tail v1.4.11
github.com/openai/openai-go v1.12.0
- github.com/pressly/goose/v3 v3.25.0
+ github.com/pressly/goose/v3 v3.26.0
github.com/qjebbs/go-jsons v1.0.0-alpha.4
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sahilm/fuzzy v0.1.1
@@ -72,11 +73,11 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
- github.com/charmbracelet/colorprofile v0.3.2 // indirect
- github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef
+ github.com/charmbracelet/colorprofile v0.3.2
+ github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d
- github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4
+ github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
github.com/charmbracelet/x/term v0.2.1
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -91,6 +92,7 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/jsonschema-go v0.3.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
@@ -104,7 +106,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/mattn/go-runewidth v0.0.17 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -116,12 +118,11 @@ require (
github.com/ncruces/julianday v1.0.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/posthog/posthog-go v1.6.10
+ github.com/posthog/posthog-go v1.6.11
github.com/rivo/uniseg v0.4.7
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
- github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
@@ -141,18 +142,18 @@ require (
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/crypto v0.41.0 // indirect
+ golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.17.0 // indirect
- golang.org/x/sys v0.36.0 // indirect
- golang.org/x/term v0.34.0 // indirect
- golang.org/x/text v0.29.0
+ golang.org/x/sys v0.37.0 // indirect
+ golang.org/x/term v0.35.0 // indirect
+ golang.org/x/text v0.30.0
golang.org/x/time v0.8.0 // indirect
google.golang.org/api v0.211.0 // indirect
- google.golang.org/genai v1.26.0
+ google.golang.org/genai v1.31.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
@@ -30,8 +30,6 @@ github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
-github.com/anthropics/anthropic-sdk-go v1.12.0 h1:xPqlGnq7rWrTiHazIvCiumA0u7mGQnwDQtvA1M82h9U=
-github.com/anthropics/anthropic-sdk-go v1.12.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
@@ -76,36 +74,40 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg=
github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
+github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
+github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250910155747-997384b0b35e h1:4BBnKWFwJ5FLyhw/ijFxKE04i9rubr8WIPR1kjO57iA=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250910155747-997384b0b35e/go.mod h1:F7AfLKYQqpM3NNBVs7ctW417tavhvoh9SBjsgtwpzbY=
-github.com/charmbracelet/catwalk v0.6.1 h1:2rRqUlwo+fdyIty8jEvUufRTgqBl0aea21LV6YQPqb0=
-github.com/charmbracelet/catwalk v0.6.1/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5 h1:oAChAeh730gtLKK/BpaTeJHzmj3KFuEfQ7AZgf2VGHM=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5/go.mod h1:SUTLq+/pGQ5qntHgt0JswfVJFfgJgWDqyvyiSLVlmbo=
+github.com/charmbracelet/catwalk v0.7.0 h1:qhLv56aeel5Q+2G/YFh9k5FhTqsozsn4HYViuAQ/Rio=
+github.com/charmbracelet/catwalk v0.7.0/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
-github.com/charmbracelet/fang v0.4.2 h1:nWr7Tb82/TTNNGMGG35aTZ1X68loAOQmpb0qxkKXjas=
-github.com/charmbracelet/fang v0.4.2/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg=
+github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY=
+github.com/charmbracelet/fang v0.4.3/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg=
github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 h1:PU4Zvpagsk5sgaDxn5W4sxHuLp9QRMBZB3bFSk40A4w=
github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018/go.mod h1:Z/GLmp9fzaqX4ze3nXG7StgWez5uBM5XtlLHK8V/qSk=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea/go.mod h1:ngHerf1JLJXBrDXdphn5gFrBPriCL437uwukd5c93pM=
github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
-github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef h1:VrWaUi2LXYLjfjCHowdSOEc6dQ9Ro14KY7Bw4IWd19M=
-github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef/go.mod h1:AThRsQH1t+dfyOKIwXRoJBniYFQUkUpQq4paheHMc2o=
-github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
-github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731 h1:Lr+igmzKpLPdb8yUZBP9noYWwCZP042z2nWPrJZTc+8=
+github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731/go.mod h1:KfWwUa0Oe//D72YlhbOq/g40L7UiGtATrvsGI3cciG8=
+github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
+github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a h1:zYSNtEJM9jwHbJts2k+Hroj+xQwsW1yxc4Wopdv7KaI=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a/go.mod h1:rc2bsPC6MWae3LdOxNO1mOb443NlMrrDL0xEya48NNc=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
+github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA=
github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
-github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0=
-github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
+github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
+github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -130,8 +132,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
-github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
@@ -144,13 +144,15 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
-github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
-github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
+github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -194,18 +196,18 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/mark3labs/mcp-go v0.40.0 h1:M0oqK412OHBKut9JwXSsj4KanSmEKpzoW8TcxoPOkAU=
-github.com/mark3labs/mcp-go v0.40.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
+github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
+github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
@@ -218,8 +220,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
-github.com/ncruces/go-sqlite3 v0.29.0 h1:1tsLiagCoqZEfcHDeKsNSv5jvrY/Iu393pAnw2wLNJU=
-github.com/ncruces/go-sqlite3 v0.29.0/go.mod h1:r1hSvYKPNJ+OlUA1O3r8o9LAawzPAlqeZiIdxTBBBJ0=
+github.com/ncruces/go-sqlite3 v0.29.1 h1:NIi8AISWBToRHyoz01FXiTNvU147Tqdibgj2tFzJCqM=
+github.com/ncruces/go-sqlite3 v0.29.1/go.mod h1:PpccBNNhvjwUOwDQEn2gXQPFPTWdlromj0+fSkd5KSg=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@@ -237,10 +239,10 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posthog/posthog-go v1.6.10 h1:OA6bkiUg89rI7f5cSXbcrH5+wLinyS6hHplnD92Pu/M=
-github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
-github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
-github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
+github.com/posthog/posthog-go v1.6.11 h1:5G8Y3pxnOpc3S4+PK1z1dCmZRuldiWxBsqqvvSfC2+w=
+github.com/posthog/posthog-go v1.6.11/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
+github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
+github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
github.com/qjebbs/go-jsons v1.0.0-alpha.4/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -265,8 +267,6 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sourcegraph/jsonrpc2 v0.2.1 h1:2GtljixMQYUYCmIg7W9aF2dFmniq/mOr2T9tFRh6zSQ=
github.com/sourcegraph/jsonrpc2 v0.2.1/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=
-github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
-github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
@@ -338,8 +338,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
-golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
+golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
+golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
@@ -388,8 +388,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
-golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -401,8 +401,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
-golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
+golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
+golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -412,8 +412,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -422,11 +422,13 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg=
google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0=
-google.golang.org/genai v1.26.0 h1:r4HGL54kFv/WCRMTAbZg05Ct+vXfhAbTRlXhFyBkEQo=
-google.golang.org/genai v1.26.0/go.mod h1:OClfdf+r5aaD+sCd4aUSkPzJItmg2wD/WON9lQnRPaY=
+google.golang.org/genai v1.31.0 h1:R7xDt/Dosz11vcXbZ4IgisGnzUGGau2PZOIOAnXsYjw=
+google.golang.org/genai v1.31.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
@@ -17,12 +17,12 @@ import (
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/llm/agent"
"github.com/charmbracelet/crush/internal/log"
- "github.com/charmbracelet/crush/internal/pubsub"
-
"github.com/charmbracelet/crush/internal/lsp"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/permission"
+ "github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/x/ansi"
)
type App struct {
@@ -107,7 +107,6 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
ctx, cancel := context.WithCancel(ctx)
defer cancel()
- // Start spinner if not in quiet mode.
var spinner *format.Spinner
if !quiet {
spinner = format.NewSpinner(ctx, cancel, "Generating")
@@ -151,7 +150,11 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
messageEvents := app.Messages.Subscribe(ctx)
messageReadBytes := make(map[string]int)
+ defer fmt.Printf(ansi.ResetProgressBar)
for {
+ // HACK: add it again on every iteration so it doesn't get hidden by
+ // the terminal due to inactivity.
+ fmt.Printf(ansi.SetIndeterminateProgressBar)
select {
case result := <-done:
stopSpinner()
@@ -0,0 +1,66 @@
+package cmd
+
+import (
+ "os"
+ "path/filepath"
+
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/lipgloss/v2/table"
+ "github.com/charmbracelet/x/term"
+ "github.com/spf13/cobra"
+)
+
+var dirsCmd = &cobra.Command{
+ Use: "dirs",
+ Short: "Print directories used by Crush",
+ Long: `Print the directories where Crush stores its configuration and data files.
+This includes the global configuration directory and data directory.`,
+ Example: `
+# Print all directories
+crush dirs
+
+# Print only the config directory
+crush dirs config
+
+# Print only the data directory
+crush dirs data
+ `,
+ Run: func(cmd *cobra.Command, args []string) {
+ if term.IsTerminal(os.Stdout.Fd()) {
+ // We're in a TTY: make it fancy.
+ t := table.New().
+ Border(lipgloss.RoundedBorder()).
+ StyleFunc(func(row, col int) lipgloss.Style {
+ return lipgloss.NewStyle().Padding(0, 2)
+ }).
+ Row("Config", filepath.Dir(config.GlobalConfig())).
+ Row("Data", filepath.Dir(config.GlobalConfigData()))
+ lipgloss.Println(t)
+ return
+ }
+ // Not a TTY.
+ cmd.Println(filepath.Dir(config.GlobalConfig()))
+ cmd.Println(filepath.Dir(config.GlobalConfigData()))
+ },
+}
+
+var configDirCmd = &cobra.Command{
+ Use: "config",
+ Short: "Print the configuration directory used by Crush",
+ Run: func(cmd *cobra.Command, args []string) {
+ cmd.Println(filepath.Dir(config.GlobalConfig()))
+ },
+}
+
+var dataDirCmd = &cobra.Command{
+ Use: "data",
+ Short: "Print the datauration directory used by Crush",
+ Run: func(cmd *cobra.Command, args []string) {
+ cmd.Println(filepath.Dir(config.GlobalConfigData()))
+ },
+}
+
+func init() {
+ dirsCmd.AddCommand(configDirCmd, dataDirCmd)
+}
@@ -0,0 +1,46 @@
+package cmd
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func init() {
+ os.Setenv("XDG_CONFIG_HOME", "/tmp/fakeconfig")
+ os.Setenv("XDG_DATA_HOME", "/tmp/fakedata")
+}
+
+func TestDirs(t *testing.T) {
+ var b bytes.Buffer
+ dirsCmd.SetOut(&b)
+ dirsCmd.SetErr(&b)
+ dirsCmd.SetIn(bytes.NewReader(nil))
+ dirsCmd.Run(dirsCmd, nil)
+ expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n" +
+ filepath.FromSlash("/tmp/fakedata/crush") + "\n"
+ require.Equal(t, expected, b.String())
+}
+
+func TestConfigDir(t *testing.T) {
+ var b bytes.Buffer
+ configDirCmd.SetOut(&b)
+ configDirCmd.SetErr(&b)
+ configDirCmd.SetIn(bytes.NewReader(nil))
+ configDirCmd.Run(configDirCmd, nil)
+ expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n"
+ require.Equal(t, expected, b.String())
+}
+
+func TestDataDir(t *testing.T) {
+ var b bytes.Buffer
+ dataDirCmd.SetOut(&b)
+ dataDirCmd.SetErr(&b)
+ dataDirCmd.SetIn(bytes.NewReader(nil))
+ dataDirCmd.Run(dataDirCmd, nil)
+ expected := filepath.FromSlash("/tmp/fakedata/crush") + "\n"
+ require.Equal(t, expected, b.String())
+}
@@ -10,8 +10,10 @@ import (
"slices"
"time"
+ "github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/log/v2"
+ "github.com/charmbracelet/x/term"
"github.com/nxadm/tail"
"github.com/spf13/cobra"
)
@@ -45,6 +47,9 @@ var logsCmd = &cobra.Command{
log.SetLevel(log.DebugLevel)
log.SetOutput(os.Stdout)
+ if !term.IsTerminal(os.Stdout.Fd()) {
+ log.SetColorProfile(colorprofile.NoTTY)
+ }
cfg, err := config.Load(cwd, dataDir, false)
if err != nil {
@@ -68,7 +73,6 @@ var logsCmd = &cobra.Command{
func init() {
logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
logsCmd.Flags().IntP("tail", "t", defaultTailLines, "Show only the last N lines default: 1000 for performance")
- rootCmd.AddCommand(logsCmd)
}
func followLogs(ctx context.Context, logsFile string, tailLines int) error {
@@ -1,7 +1,9 @@
package cmd
import (
+ "bytes"
"context"
+ "errors"
"fmt"
"io"
"log/slog"
@@ -10,6 +12,7 @@ import (
"strconv"
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/db"
@@ -17,6 +20,8 @@ import (
"github.com/charmbracelet/crush/internal/tui"
"github.com/charmbracelet/crush/internal/version"
"github.com/charmbracelet/fang"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/exp/charmtone"
"github.com/charmbracelet/x/term"
"github.com/spf13/cobra"
)
@@ -29,8 +34,13 @@ func init() {
rootCmd.Flags().BoolP("help", "h", false, "Help")
rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
- rootCmd.AddCommand(runCmd)
- rootCmd.AddCommand(updateProvidersCmd)
+ rootCmd.AddCommand(
+ runCmd,
+ dirsCmd,
+ updateProvidersCmd,
+ logsCmd,
+ schemaCmd,
+ )
}
var rootCmd = &cobra.Command{
@@ -73,18 +83,15 @@ crush -y
// Set up the TUI.
program := tea.NewProgram(
tui.New(app),
- tea.WithAltScreen(),
tea.WithContext(cmd.Context()),
- tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
- tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
- )
+ tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
go app.Subscribe(program)
if _, err := program.Run(); err != nil {
event.Error(err)
slog.Error("TUI run error", "error", err)
- return fmt.Errorf("TUI error: %v", err)
+ return errors.New("Crush crashed. If metrics are enabled, we were notified about it. If you'd like to report it, please copy the stacktrace above and open an issue at https://github.com/charmbracelet/crush/issues/new?template=bug.yml") //nolint:staticcheck
}
return nil
},
@@ -93,7 +100,39 @@ crush -y
},
}
+var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
+ ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
+ ███████████ ███████████
+████████████████████████████
+████████████████████████████
+██████████▀██████▀██████████
+██████████ ██████ ██████████
+▀▀██████▄████▄▄████▄██████▀▀
+ ████████████████████████
+ ████████████████████
+ ▀▀██████████▀▀
+ ▀▀▀▀▀▀
+`)
+
+// copied from cobra:
+const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
+`
+
func Execute() {
+ // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
+ // it forward to a bytes.Buffer, write the colored heartbit to it, and then
+ // finally prepend it in the version template.
+ // Unfortunately cobra doesn't give us a way to set a function to handle
+ // printing the version, and PreRunE runs after the version is already
+ // handled, so that doesn't work either.
+ // This is the only way I could find that works relatively well.
+ if term.IsTerminal(os.Stdout.Fd()) {
+ var b bytes.Buffer
+ w := colorprofile.NewWriter(os.Stdout, os.Environ())
+ w.Forward = &b
+ _, _ = w.WriteString(heartbit.String())
+ rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
+ }
if err := fang.Execute(
context.Background(),
rootCmd,
@@ -24,7 +24,3 @@ var schemaCmd = &cobra.Command{
return nil
},
}
-
-func init() {
- rootCmd.AddCommand(schemaCmd)
-}
@@ -99,7 +99,7 @@ type MCPType string
const (
MCPStdio MCPType = "stdio"
- MCPSse MCPType = "sse"
+ MCPSSE MCPType = "sse"
MCPHttp MCPType = "http"
)
@@ -132,6 +132,19 @@ type TUIOptions struct {
DiffMode string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
Transparent bool `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"`
// Here we can add themes later or any TUI related options
+ //
+
+ Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
+}
+
+// Completions defines options for the completions UI.
+type Completions struct {
+ MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
+ MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
+}
+
+func (c Completions) Limits() (depth, items int) {
+ return ptrValOr(c.MaxDepth, 0), ptrValOr(c.MaxItems, 0)
}
type Permissions struct {
@@ -247,6 +260,19 @@ type Agent struct {
ContextPaths []string `json:"context_paths,omitempty"`
}
+type Tools struct {
+ Ls ToolLs `json:"ls,omitzero"`
+}
+
+type ToolLs struct {
+ MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
+ MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
+}
+
+func (t ToolLs) Limits() (depth, items int) {
+ return ptrValOr(t.MaxDepth, 0), ptrValOr(t.MaxItems, 0)
+}
+
// Config holds the configuration for crush.
type Config struct {
Schema string `json:"$schema,omitempty"`
@@ -265,6 +291,8 @@ type Config struct {
Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"`
+ Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
+
// Internal
workingDir string `json:"-"`
// TODO: most likely remove this concept when I come back to it
@@ -580,3 +608,10 @@ func resolveEnvs(envs map[string]string) []string {
}
return res
}
+
+func ptrValOr[T any](t *T, el T) T {
+ if t == nil {
+ return el
+ }
+ return *t
+}
@@ -1,12 +1,14 @@
package config
import (
+ "context"
"encoding/json"
"fmt"
"io"
"log/slog"
"maps"
"os"
+ "os/exec"
"path/filepath"
"runtime"
"slices"
@@ -62,6 +64,16 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
cfg.Options.Debug,
)
+ if !isInsideWorktree() {
+ const depth = 2
+ const items = 100
+ slog.Warn("No git repository detected in working directory, will limit file walk operations", "depth", depth, "items", items)
+ assignIfNil(&cfg.Tools.Ls.MaxDepth, depth)
+ assignIfNil(&cfg.Tools.Ls.MaxItems, items)
+ assignIfNil(&cfg.Options.TUI.Completions.MaxDepth, depth)
+ assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items)
+ }
+
// Load known providers, this loads the config from catwalk
providers, err := Providers(cfg)
if err != nil {
@@ -520,7 +532,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
func lookupConfigs(cwd string) []string {
// prepend default config paths
configPaths := []string{
- globalConfig(),
+ GlobalConfig(),
GlobalConfigData(),
}
@@ -577,6 +589,10 @@ func hasVertexCredentials(env env.Env) bool {
}
func hasAWSCredentials(env env.Env) bool {
+ if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
+ return true
+ }
+
if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
return true
}
@@ -593,10 +609,16 @@ func hasAWSCredentials(env env.Env) bool {
env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
return true
}
+
+ if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil {
+ return true
+ }
+
return false
}
-func globalConfig() string {
+// GlobalConfig returns the global configuration file path for the application.
+func GlobalConfig() string {
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
if xdgConfigHome != "" {
return filepath.Join(xdgConfigHome, appName, fmt.Sprintf("%s.json", appName))
@@ -637,3 +659,18 @@ func GlobalConfigData() string {
return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
}
+
+func assignIfNil[T any](ptr **T, val T) {
+ if *ptr == nil {
+ *ptr = &val
+ }
+}
+
+func isInsideWorktree() bool {
+ bts, err := exec.CommandContext(
+ context.Background(),
+ "git", "rev-parse",
+ "--is-inside-work-tree",
+ ).CombinedOutput()
+ return err == nil && strings.TrimSpace(string(bts)) == "true"
+}
@@ -126,7 +126,7 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) {
}
func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) {
- cacheIsStale, cacheExists := isCacheStale(path)
+ _, cacheExists := isCacheStale(path)
catwalkGetAndSave := func() ([]catwalk.Provider, error) {
providers, err := client.GetProviders()
@@ -142,25 +142,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
return providers, nil
}
- backgroundCacheUpdate := func() {
- go func() {
- slog.Info("Updating providers cache in background", "path", path)
-
- providers, err := client.GetProviders()
- if err != nil {
- slog.Error("Failed to fetch providers in background from Catwalk", "error", err)
- return
- }
- if len(providers) == 0 {
- slog.Error("Empty providers list from Catwalk")
- return
- }
- if err := saveProvidersInCache(path, providers); err != nil {
- slog.Error("Failed to update providers.json in background", "error", err)
- }
- }()
- }
-
switch {
case autoUpdateDisabled:
slog.Warn("Providers auto-update is disabled")
@@ -177,19 +158,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
}
return providers, nil
- case cacheExists && !cacheIsStale:
- slog.Info("Recent providers cache is available.", "path", path)
-
- providers, err := loadProvidersFromCache(path)
- if err != nil {
- return nil, err
- }
- if len(providers) == 0 {
- return catwalkGetAndSave()
- }
- backgroundCacheUpdate()
- return providers, nil
-
default:
slog.Info("Cache is not available or is stale. Fetching providers from Catwalk.", "path", path)
@@ -57,7 +57,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) {
if err != nil {
t.Fatalf("Failed to write old providers to file: %v", err)
}
- providers, err := loadProviders(false, client, tmpPath)
+ providers, err := loadProviders(true, client, tmpPath)
require.NoError(t, err)
require.NotNil(t, providers)
require.Len(t, providers, 1)
@@ -27,6 +27,25 @@ func NewMapFrom[K comparable, V any](m map[K]V) *Map[K, V] {
}
}
+// NewLazyMap creates a new lazy-loaded map. The provided load function is
+// executed in a separate goroutine to populate the map.
+func NewLazyMap[K comparable, V any](load func() map[K]V) *Map[K, V] {
+ m := &Map[K, V]{}
+ m.mu.Lock()
+ go func() {
+ m.inner = load()
+ m.mu.Unlock()
+ }()
+ return m
+}
+
+// Reset replaces the inner map with the new one.
+func (m *Map[K, V]) Reset(input map[K]V) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.inner = input
+}
+
// Set sets the value for the specified key in the map.
func (m *Map[K, V]) Set(key K, value V) {
m.mu.Lock()
@@ -5,6 +5,8 @@ import (
"maps"
"sync"
"testing"
+ "testing/synctest"
+ "time"
"github.com/stretchr/testify/require"
)
@@ -36,6 +38,56 @@ func TestNewMapFrom(t *testing.T) {
require.Equal(t, 1, value)
}
+func TestNewLazyMap(t *testing.T) {
+ t.Parallel()
+
+ synctest.Test(t, func(t *testing.T) {
+ t.Helper()
+
+ waiter := sync.Mutex{}
+ waiter.Lock()
+ loadCalled := false
+
+ loadFunc := func() map[string]int {
+ waiter.Lock()
+ defer waiter.Unlock()
+ loadCalled = true
+ return map[string]int{
+ "key1": 1,
+ "key2": 2,
+ }
+ }
+
+ m := NewLazyMap(loadFunc)
+ require.NotNil(t, m)
+
+ waiter.Unlock() // Allow the load function to proceed
+ time.Sleep(100 * time.Millisecond)
+ require.True(t, loadCalled)
+ require.Equal(t, 2, m.Len())
+
+ value, ok := m.Get("key1")
+ require.True(t, ok)
+ require.Equal(t, 1, value)
+ })
+}
+
+func TestMap_Reset(t *testing.T) {
+ t.Parallel()
+
+ m := NewMapFrom(map[string]int{
+ "a": 10,
+ })
+
+ m.Reset(map[string]int{
+ "b": 20,
+ })
+ value, ok := m.Get("b")
+ require.True(t, ok)
+ require.Equal(t, 20, value)
+ require.Equal(t, 1, m.Len())
+}
+
func TestMap_Set(t *testing.T) {
t.Parallel()
@@ -1,6 +1,7 @@
package event
import (
+ "fmt"
"log/slog"
"github.com/posthog/posthog-go"
@@ -11,17 +12,17 @@ var _ posthog.Logger = logger{}
type logger struct{}
func (logger) Debugf(format string, args ...any) {
- slog.Debug(format, args...)
+ slog.Debug(fmt.Sprintf(format, args...))
}
func (logger) Logf(format string, args ...any) {
- slog.Info(format, args...)
+ slog.Info(fmt.Sprintf(format, args...))
}
func (logger) Warnf(format string, args ...any) {
- slog.Warn(format, args...)
+ slog.Warn(fmt.Sprintf(format, args...))
}
func (logger) Errorf(format string, args ...any) {
- slog.Error(format, args...)
+ slog.Error(fmt.Sprintf(format, args...))
}
@@ -23,8 +23,8 @@ type model struct {
anim *anim.Anim
}
-func (m model) Init() tea.Cmd { return m.anim.Init() }
-func (m model) View() string { return m.anim.View() }
+func (m model) Init() tea.Cmd { return m.anim.Init() }
+func (m model) View() tea.View { return tea.NewView(m.anim.View()) }
// Update implements tea.Model.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -1,15 +1,17 @@
package fsext
import (
+ "errors"
"fmt"
"os"
"path/filepath"
- "sort"
+ "slices"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/charlievieth/fastwalk"
+ "github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/home"
)
@@ -80,10 +82,9 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
pattern = filepath.ToSlash(pattern)
walker := NewFastGlobWalker(searchPath)
- var matches []FileInfo
+ found := csync.NewSlice[FileInfo]()
conf := fastwalk.Config{
- Follow: true,
- // Use forward slashes when running a Windows binary under WSL or MSYS
+ Follow: true,
ToSlash: fastwalk.DefaultToSlash(),
Sort: fastwalk.SortFilesFirst,
}
@@ -121,31 +122,26 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
return nil
}
- matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
- if limit > 0 && len(matches) >= limit*2 {
+ found.Append(FileInfo{Path: path, ModTime: info.ModTime()})
+ if limit > 0 && found.Len() >= limit*2 { // NOTE: why x2?
return filepath.SkipAll
}
return nil
})
- if err != nil {
+ if err != nil && !errors.Is(err, filepath.SkipAll) {
return nil, false, fmt.Errorf("fastwalk error: %w", err)
}
- sort.Slice(matches, func(i, j int) bool {
- return matches[i].ModTime.After(matches[j].ModTime)
+ matches := slices.SortedFunc(found.Seq(), func(a, b FileInfo) int {
+ return b.ModTime.Compare(a.ModTime)
})
-
- truncated := false
- if limit > 0 && len(matches) > limit {
- matches = matches[:limit]
- truncated = true
- }
+ matches, truncated := truncate(matches, limit)
results := make([]string, len(matches))
for i, m := range matches {
results[i] = m.Path
}
- return results, truncated, nil
+ return results, truncated || errors.Is(err, filepath.SkipAll), nil
}
// ShouldExcludeFile checks if a file should be excluded from processing
@@ -155,36 +151,6 @@ func ShouldExcludeFile(rootPath, filePath string) bool {
shouldIgnore(filePath, nil)
}
-// WalkDirectories walks a directory tree and calls the provided function for each directory,
-// respecting hierarchical .gitignore/.crushignore files like git does.
-func WalkDirectories(rootPath string, fn func(path string, d os.DirEntry, err error) error) error {
- dl := NewDirectoryLister(rootPath)
-
- conf := fastwalk.Config{
- Follow: true,
- ToSlash: fastwalk.DefaultToSlash(),
- Sort: fastwalk.SortDirsFirst,
- }
-
- return fastwalk.Walk(&conf, rootPath, func(path string, d os.DirEntry, err error) error {
- if err != nil {
- return fn(path, d, err)
- }
-
- // Only process directories
- if !d.IsDir() {
- return nil
- }
-
- // Check if directory should be ignored
- if dl.shouldIgnore(path, nil) {
- return filepath.SkipDir
- }
-
- return fn(path, d, err)
- })
-}
-
func PrettyPath(path string) string {
return home.Short(path)
}
@@ -248,3 +214,10 @@ func ToWindowsLineEndings(content string) (string, bool) {
}
return content, false
}
+
+func truncate[T any](input []T, limit int) ([]T, bool) {
+ if limit > 0 && len(input) > limit {
+ return input[:limit], true
+ }
+ return input, false
+}
@@ -5,7 +5,6 @@ import (
"os"
"path/filepath"
"testing"
- "testing/synctest"
"time"
"github.com/stretchr/testify/require"
@@ -148,37 +147,35 @@ func TestGlobWithDoubleStar(t *testing.T) {
require.NoError(t, err)
require.False(t, truncated)
- require.Equal(t, matches, []string{file1})
+ require.Equal(t, []string{file1}, matches)
})
t.Run("returns results sorted by modification time (newest first)", func(t *testing.T) {
- synctest.Test(t, func(t *testing.T) {
- testDir := t.TempDir()
+ testDir := t.TempDir()
- file1 := filepath.Join(testDir, "file1.txt")
- require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
+ file1 := filepath.Join(testDir, "file1.txt")
+ require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
- file2 := filepath.Join(testDir, "file2.txt")
- require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
+ file2 := filepath.Join(testDir, "file2.txt")
+ require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
- file3 := filepath.Join(testDir, "file3.txt")
- require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
+ file3 := filepath.Join(testDir, "file3.txt")
+ require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
- base := time.Now()
- m1 := base
- m2 := base.Add(1 * time.Millisecond)
- m3 := base.Add(2 * time.Millisecond)
+ base := time.Now()
+ m1 := base
+ m2 := base.Add(10 * time.Hour)
+ m3 := base.Add(20 * time.Hour)
- require.NoError(t, os.Chtimes(file1, m1, m1))
- require.NoError(t, os.Chtimes(file2, m2, m2))
- require.NoError(t, os.Chtimes(file3, m3, m3))
+ require.NoError(t, os.Chtimes(file1, m1, m1))
+ require.NoError(t, os.Chtimes(file2, m2, m2))
+ require.NoError(t, os.Chtimes(file3, m3, m3))
- matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
- require.NoError(t, err)
- require.False(t, truncated)
+ matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
+ require.NoError(t, err)
+ require.False(t, truncated)
- require.Equal(t, matches, []string{file3, file2, file1})
- })
+ require.Equal(t, []string{file3, file2, file1}, matches)
})
t.Run("handles empty directory", func(t *testing.T) {
@@ -188,7 +185,7 @@ func TestGlobWithDoubleStar(t *testing.T) {
require.NoError(t, err)
require.False(t, truncated)
// Even empty directories should return the directory itself
- require.Equal(t, matches, []string{testDir})
+ require.Equal(t, []string{testDir}, matches)
})
t.Run("handles non-existent search path", func(t *testing.T) {
@@ -235,39 +232,38 @@ func TestGlobWithDoubleStar(t *testing.T) {
matches, truncated, err = GlobWithDoubleStar("*.txt", testDir, 0)
require.NoError(t, err)
require.False(t, truncated)
- require.Equal(t, matches, []string{goodFile})
+ require.Equal(t, []string{goodFile}, matches)
})
t.Run("handles mixed file and directory matching with sorting", func(t *testing.T) {
- synctest.Test(t, func(t *testing.T) {
- testDir := t.TempDir()
+ testDir := t.TempDir()
- oldestFile := filepath.Join(testDir, "old.test")
- require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
+ oldestFile := filepath.Join(testDir, "old.rs")
+ require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
- middleDir := filepath.Join(testDir, "mid.test")
- require.NoError(t, os.MkdirAll(middleDir, 0o755))
+ middleDir := filepath.Join(testDir, "mid.rs")
+ require.NoError(t, os.MkdirAll(middleDir, 0o755))
- newestFile := filepath.Join(testDir, "new.test")
- require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
+ newestFile := filepath.Join(testDir, "new.rs")
+ require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
- base := time.Now()
- tOldest := base
- tMiddle := base.Add(1 * time.Millisecond)
- tNewest := base.Add(2 * time.Millisecond)
+ base := time.Now()
+ tOldest := base
+ tMiddle := base.Add(10 * time.Hour)
+ tNewest := base.Add(20 * time.Hour)
- // Reverse the expected order
- require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
- require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
- require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
+ // Reverse the expected order
+ require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
+ require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
+ require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
- matches, truncated, err := GlobWithDoubleStar("*.test", testDir, 0)
- require.NoError(t, err)
- require.False(t, truncated)
+ matches, truncated, err := GlobWithDoubleStar("*.rs", testDir, 0)
+ require.NoError(t, err)
+ require.False(t, truncated)
+ require.Len(t, matches, 3)
- // Results should be sorted by mod time, but we set the oldestFile
- // to have the most recent mod time
- require.Equal(t, matches, []string{oldestFile, middleDir, newestFile})
- })
+ // Results should be sorted by mod time, but we set the oldestFile
+ // to have the most recent mod time
+ require.Equal(t, []string{oldestFile, middleDir, newestFile}, matches)
})
}
@@ -9,14 +9,8 @@ import (
)
func TestCrushIgnore(t *testing.T) {
- // Create a temporary directory for testing
tempDir := t.TempDir()
-
- // Change to temp directory
- oldWd, _ := os.Getwd()
- err := os.Chdir(tempDir)
- require.NoError(t, err)
- defer os.Chdir(oldWd)
+ t.Chdir(tempDir)
// Create test files
require.NoError(t, os.WriteFile("test1.txt", []byte("test"), 0o644))
@@ -12,15 +12,7 @@ import (
func TestLookupClosest(t *testing.T) {
tempDir := t.TempDir()
-
- // Change to temp directory
- oldWd, _ := os.Getwd()
- err := os.Chdir(tempDir)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- os.Chdir(oldWd)
- })
+ t.Chdir(tempDir)
t.Run("target found in starting directory", func(t *testing.T) {
testDir := t.TempDir()
@@ -114,24 +106,15 @@ func TestLookupClosest(t *testing.T) {
})
t.Run("relative path handling", func(t *testing.T) {
- testDir := t.TempDir()
-
- // Change to test directory
- oldWd, _ := os.Getwd()
- err := os.Chdir(testDir)
- require.NoError(t, err)
- defer os.Chdir(oldWd)
-
// Create target file in current directory
- err = os.WriteFile("target.txt", []byte("test"), 0o644)
- require.NoError(t, err)
+ require.NoError(t, os.WriteFile("target.txt", []byte("test"), 0o644))
// Search using relative path
foundPath, found := LookupClosest(".", "target.txt")
require.True(t, found)
// Resolve symlinks to handle macOS /private/var vs /var discrepancy
- expectedPath, err := filepath.EvalSymlinks(filepath.Join(testDir, "target.txt"))
+ expectedPath, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target.txt"))
require.NoError(t, err)
actualPath, err := filepath.EvalSymlinks(foundPath)
require.NoError(t, err)
@@ -145,15 +128,7 @@ func TestLookupClosestWithOwnership(t *testing.T) {
// This test focuses on the basic functionality when ownership checks pass.
tempDir := t.TempDir()
-
- // Change to temp directory
- oldWd, _ := os.Getwd()
- err := os.Chdir(tempDir)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- os.Chdir(oldWd)
- })
+ t.Chdir(tempDir)
t.Run("search respects same ownership", func(t *testing.T) {
testDir := t.TempDir()
@@ -177,15 +152,7 @@ func TestLookupClosestWithOwnership(t *testing.T) {
func TestLookup(t *testing.T) {
tempDir := t.TempDir()
-
- // Change to temp directory
- oldWd, _ := os.Getwd()
- err := os.Chdir(tempDir)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- os.Chdir(oldWd)
- })
+ t.Chdir(tempDir)
t.Run("no targets returns empty slice", func(t *testing.T) {
testDir := t.TempDir()
@@ -358,22 +325,9 @@ func TestLookup(t *testing.T) {
})
t.Run("relative path handling", func(t *testing.T) {
- testDir := t.TempDir()
-
- // Change to test directory
- oldWd, _ := os.Getwd()
- err := os.Chdir(testDir)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- os.Chdir(oldWd)
- })
-
// Create target files in current directory
- err = os.WriteFile("target1.txt", []byte("test1"), 0o644)
- require.NoError(t, err)
- err = os.WriteFile("target2.txt", []byte("test2"), 0o644)
- require.NoError(t, err)
+ require.NoError(t, os.WriteFile("target1.txt", []byte("test1"), 0o644))
+ require.NoError(t, os.WriteFile("target2.txt", []byte("test2"), 0o644))
// Search using relative path
found, err := Lookup(".", "target1.txt", "target2.txt")
@@ -381,9 +335,9 @@ func TestLookup(t *testing.T) {
require.Len(t, found, 2)
// Resolve symlinks to handle macOS /private/var vs /var discrepancy
- expectedPath1, err := filepath.EvalSymlinks(filepath.Join(testDir, "target1.txt"))
+ expectedPath1, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target1.txt"))
require.NoError(t, err)
- expectedPath2, err := filepath.EvalSymlinks(filepath.Join(testDir, "target2.txt"))
+ expectedPath2, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target2.txt"))
require.NoError(t, err)
// Check that found paths match expected paths (order may vary)
@@ -1,6 +1,7 @@
package fsext
import (
+ "errors"
"log/slog"
"os"
"path/filepath"
@@ -71,6 +72,11 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
// Crush
".crush",
+
+ // macOS stuff
+ "OrbStack",
+ ".local",
+ ".share",
)
})
@@ -200,16 +206,17 @@ func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
}
// ListDirectory lists files and directories in the specified path,
-func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
- results := csync.NewSlice[string]()
- truncated := false
+func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
+ found := csync.NewSlice[string]()
dl := NewDirectoryLister(initialPath)
+ slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
+
conf := fastwalk.Config{
- Follow: true,
- // Use forward slashes when running a Windows binary under WSL or MSYS
- ToSlash: fastwalk.DefaultToSlash(),
- Sort: fastwalk.SortDirsFirst,
+ Follow: true,
+ ToSlash: fastwalk.DefaultToSlash(),
+ Sort: fastwalk.SortDirsFirst,
+ MaxDepth: depth,
}
err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
@@ -228,19 +235,19 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
if d.IsDir() {
path = path + string(filepath.Separator)
}
- results.Append(path)
+ found.Append(path)
}
- if limit > 0 && results.Len() >= limit {
- truncated = true
+ if limit > 0 && found.Len() >= limit {
return filepath.SkipAll
}
return nil
})
- if err != nil && results.Len() == 0 {
- return nil, truncated, err
+ if err != nil && !errors.Is(err, filepath.SkipAll) {
+ return nil, false, err
}
- return slices.Collect(results.Seq()), truncated, nil
+ matches, truncated := truncate(slices.Collect(found.Seq()), limit)
+ return matches, truncated || errors.Is(err, filepath.SkipAll), nil
}
@@ -5,26 +5,11 @@ import (
"path/filepath"
"testing"
- "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func chdir(t *testing.T, dir string) {
- original, err := os.Getwd()
- require.NoError(t, err)
-
- err = os.Chdir(dir)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- err := os.Chdir(original)
- require.NoError(t, err)
- })
-}
-
func TestListDirectory(t *testing.T) {
- tempDir := t.TempDir()
- chdir(t, tempDir)
+ tmp := t.TempDir()
testFiles := map[string]string{
"regular.txt": "content",
@@ -35,32 +20,40 @@ func TestListDirectory(t *testing.T) {
"build.log": "build output",
}
- for filePath, content := range testFiles {
- dir := filepath.Dir(filePath)
- if dir != "." {
- require.NoError(t, os.MkdirAll(dir, 0o755))
- }
-
- err := os.WriteFile(filePath, []byte(content), 0o644)
- require.NoError(t, err)
+ for name, content := range testFiles {
+ fp := filepath.Join(tmp, name)
+ dir := filepath.Dir(fp)
+ require.NoError(t, os.MkdirAll(dir, 0o755))
+ require.NoError(t, os.WriteFile(fp, []byte(content), 0o644))
}
- files, truncated, err := ListDirectory(".", nil, 0)
- require.NoError(t, err)
- assert.False(t, truncated)
- assert.Equal(t, len(files), 4)
+ t.Run("no limit", func(t *testing.T) {
+ files, truncated, err := ListDirectory(tmp, nil, -1, -1)
+ require.NoError(t, err)
+ require.False(t, truncated)
+ require.Len(t, files, 4)
+ require.ElementsMatch(t, []string{
+ "regular.txt",
+ "subdir",
+ "subdir/.another",
+ "subdir/file.go",
+ }, relPaths(t, files, tmp))
+ })
+ t.Run("limit", func(t *testing.T) {
+ files, truncated, err := ListDirectory(tmp, nil, -1, 2)
+ require.NoError(t, err)
+ require.True(t, truncated)
+ require.Len(t, files, 2)
+ })
+}
- fileSet := make(map[string]bool)
- for _, file := range files {
- fileSet[filepath.ToSlash(file)] = true
+func relPaths(tb testing.TB, in []string, base string) []string {
+ tb.Helper()
+ out := make([]string, 0, len(in))
+ for _, p := range in {
+ rel, err := filepath.Rel(base, p)
+ require.NoError(tb, err)
+ out = append(out, filepath.ToSlash(rel))
}
-
- assert.True(t, fileSet["./regular.txt"])
- assert.True(t, fileSet["./subdir/"])
- assert.True(t, fileSet["./subdir/file.go"])
- assert.True(t, fileSet["./regular.txt"])
-
- assert.False(t, fileSet["./.hidden"])
- assert.False(t, fileSet["./.gitignore"])
- assert.False(t, fileSet["./build.log"])
+ return out
}
@@ -1,3 +1,4 @@
+// Package agent contains the implementation of the AI agent service.
package agent
import (
@@ -5,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
+ "maps"
"slices"
"strings"
"time"
@@ -65,11 +67,13 @@ type agent struct {
sessions session.Service
messages message.Service
permissions permission.Service
- mcpTools []McpTool
+ baseTools *csync.Map[string, tools.BaseTool]
+ mcpTools *csync.Map[string, tools.BaseTool]
+ lspClients *csync.Map[string, *lsp.Client]
- tools *csync.LazySlice[tools.BaseTool]
// We need this to be able to update it when model changes
- agentToolFn func() (tools.BaseTool, error)
+ agentToolFn func() (tools.BaseTool, error)
+ cleanupFuncs []func()
provider provider.Provider
providerID string
@@ -171,14 +175,16 @@ func NewAgent(
return nil, err
}
- toolFn := func() []tools.BaseTool {
- slog.Info("Initializing agent tools", "agent", agentCfg.ID)
+ baseToolsFn := func() map[string]tools.BaseTool {
+ slog.Debug("Initializing agent base tools", "agent", agentCfg.ID)
defer func() {
- slog.Info("Initialized agent tools", "agent", agentCfg.ID)
+ slog.Debug("Initialized agent base tools", "agent", agentCfg.ID)
}()
+ // Base tools available to all agents
cwd := cfg.WorkingDir()
- allTools := []tools.BaseTool{
+ result := make(map[string]tools.BaseTool)
+ for _, tool := range []tools.BaseTool{
tools.NewBashTool(permissions, cwd, cfg.Options.Attribution),
tools.NewDownloadTool(permissions, cwd),
tools.NewEditTool(lspClients, permissions, history, cwd),
@@ -190,36 +196,25 @@ func NewAgent(
tools.NewSourcegraphTool(),
tools.NewViewTool(lspClients, permissions, cwd),
tools.NewWriteTool(lspClients, permissions, history, cwd),
+ } {
+ result[tool.Name()] = tool
}
+ return result
+ }
+ mcpToolsFn := func() map[string]tools.BaseTool {
+ slog.Debug("Initializing agent mcp tools", "agent", agentCfg.ID)
+ defer func() {
+ slog.Debug("Initialized agent mcp tools", "agent", agentCfg.ID)
+ }()
mcpToolsOnce.Do(func() {
- mcpTools = doGetMCPTools(ctx, permissions, cfg)
+ doGetMCPTools(ctx, permissions, cfg)
})
- withCoderTools := func(t []tools.BaseTool) []tools.BaseTool {
- if agentCfg.ID == "coder" {
- t = append(t, mcpTools...)
- if lspClients.Len() > 0 {
- t = append(t, tools.NewDiagnosticsTool(lspClients))
- }
- }
- return t
- }
-
- if agentCfg.AllowedTools == nil {
- return withCoderTools(allTools)
- }
-
- var filteredTools []tools.BaseTool
- for _, tool := range allTools {
- if slices.Contains(agentCfg.AllowedTools, tool.Name()) {
- filteredTools = append(filteredTools, tool)
- }
- }
- return withCoderTools(filteredTools)
+ return maps.Collect(mcpTools.Seq2())
}
- return &agent{
+ a := &agent{
Broker: pubsub.NewBroker[AgentEvent](),
agentCfg: agentCfg,
provider: agentProvider,
@@ -231,10 +226,14 @@ func NewAgent(
summarizeProviderID: string(providerCfg.ID),
agentToolFn: agentToolFn,
activeRequests: csync.NewMap[string, context.CancelFunc](),
- tools: csync.NewLazySlice(toolFn),
+ mcpTools: csync.NewLazyMap(mcpToolsFn),
+ baseTools: csync.NewLazyMap(baseToolsFn),
promptQueue: csync.NewMap[string, []string](),
permissions: permissions,
- }, nil
+ lspClients: lspClients,
+ }
+ a.setupEvents(ctx)
+ return a, nil
}
func (a *agent) Model() catwalk.Model {
@@ -517,7 +516,18 @@ func (a *agent) createUserMessage(ctx context.Context, sessionID, content string
}
func (a *agent) getAllTools() ([]tools.BaseTool, error) {
- allTools := slices.Collect(a.tools.Seq())
+ var allTools []tools.BaseTool
+ for tool := range a.baseTools.Seq() {
+ if a.agentCfg.AllowedTools == nil || slices.Contains(a.agentCfg.AllowedTools, tool.Name()) {
+ allTools = append(allTools, tool)
+ }
+ }
+ if a.agentCfg.ID == "coder" {
+ allTools = slices.AppendSeq(allTools, a.mcpTools.Seq())
+ if a.lspClients.Len() > 0 {
+ allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients), tools.NewReferencesTool(a.lspClients))
+ }
+ }
if a.agentToolFn != nil {
agentTool, agentToolErr := a.agentToolFn()
if agentToolErr != nil {
@@ -525,6 +535,10 @@ func (a *agent) getAllTools() ([]tools.BaseTool, error) {
}
allTools = append(allTools, agentTool)
}
+
+ slices.SortFunc(allTools, func(a, b tools.BaseTool) int {
+ return strings.Compare(a.Name(), b.Name())
+ })
return allTools, nil
}
@@ -552,17 +566,22 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
// Add the session and message ID into the context if needed by tools.
ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
- // Process each event in the stream.
- for event := range eventChan {
- if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
- if errors.Is(processErr, context.Canceled) {
- a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
- } else {
- a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error())
+loop:
+ for {
+ select {
+ case event, ok := <-eventChan:
+ if !ok {
+ break loop
}
- return assistantMsg, nil, processErr
- }
- if ctx.Err() != nil {
+ if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
+ if errors.Is(processErr, context.Canceled) {
+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
+ } else {
+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error())
+ }
+ return assistantMsg, nil, processErr
+ }
+ case <-ctx.Done():
a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
return assistantMsg, nil, ctx.Err()
}
@@ -586,7 +605,7 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
default:
// Continue processing
var tool tools.BaseTool
- allTools, _ := a.getAllTools()
+ allTools, _ = a.getAllTools()
for _, availableTool := range allTools {
if availableTool.Info().Name == toolCall.Name {
tool = availableTool
@@ -955,6 +974,12 @@ func (a *agent) CancelAll() {
a.Cancel(key) // key is sessionID
}
+ for _, cleanup := range a.cleanupFuncs {
+ if cleanup != nil {
+ cleanup()
+ }
+ }
+
timeout := time.After(5 * time.Second)
for a.IsBusy() {
select {
@@ -1066,3 +1091,48 @@ func (a *agent) UpdateModel() error {
return nil
}
+
+func (a *agent) setupEvents(ctx context.Context) {
+ ctx, cancel := context.WithCancel(ctx)
+
+ go func() {
+ subCh := SubscribeMCPEvents(ctx)
+
+ for {
+ select {
+ case event, ok := <-subCh:
+ if !ok {
+ slog.Debug("MCPEvents subscription channel closed")
+ return
+ }
+ switch event.Payload.Type {
+ case MCPEventToolsListChanged:
+ name := event.Payload.Name
+ c, ok := mcpClients.Get(name)
+ if !ok {
+ slog.Warn("MCP client not found for tools update", "name", name)
+ continue
+ }
+ cfg := config.Get()
+ tools, err := getTools(ctx, name, a.permissions, c, cfg.WorkingDir())
+ if err != nil {
+ slog.Error("error listing tools", "error", err)
+ updateMCPState(name, MCPStateError, err, nil, 0)
+ _ = c.Close()
+ continue
+ }
+ updateMcpTools(name, tools)
+ a.mcpTools.Reset(maps.Collect(mcpTools.Seq2()))
+ updateMCPState(name, MCPStateConnected, nil, c, a.mcpTools.Len())
+ default:
+ continue
+ }
+ case <-ctx.Done():
+ slog.Debug("MCPEvents subscription cancelled")
+ return
+ }
+ }
+ }()
+
+ a.cleanupFuncs = append(a.cleanupFuncs, cancel)
+}
@@ -6,9 +6,12 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"log/slog"
"maps"
- "slices"
+ "net/http"
+ "os"
+ "os/exec"
"strings"
"sync"
"time"
@@ -20,9 +23,7 @@ import (
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/version"
- "github.com/mark3labs/mcp-go/client"
- "github.com/mark3labs/mcp-go/client/transport"
- "github.com/mark3labs/mcp-go/mcp"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
)
// MCPState represents the current state of an MCP client
@@ -54,7 +55,8 @@ func (s MCPState) String() string {
type MCPEventType string
const (
- MCPEventStateChanged MCPEventType = "state_changed"
+ MCPEventStateChanged MCPEventType = "state_changed"
+ MCPEventToolsListChanged MCPEventType = "tools_list_changed"
)
// MCPEvent represents an event in the MCP system
@@ -71,22 +73,23 @@ type MCPClientInfo struct {
Name string
State MCPState
Error error
- Client *client.Client
+ Client *mcp.ClientSession
ToolCount int
ConnectedAt time.Time
}
var (
- mcpToolsOnce sync.Once
- mcpTools []tools.BaseTool
- mcpClients = csync.NewMap[string, *client.Client]()
- mcpStates = csync.NewMap[string, MCPClientInfo]()
- mcpBroker = pubsub.NewBroker[MCPEvent]()
+ mcpToolsOnce sync.Once
+ mcpTools = csync.NewMap[string, tools.BaseTool]()
+ mcpClient2Tools = csync.NewMap[string, []tools.BaseTool]()
+ mcpClients = csync.NewMap[string, *mcp.ClientSession]()
+ mcpStates = csync.NewMap[string, MCPClientInfo]()
+ mcpBroker = pubsub.NewBroker[MCPEvent]()
)
type McpTool struct {
mcpName string
- tool mcp.Tool
+ tool *mcp.Tool
permissions permission.Service
workingDir string
}
@@ -96,14 +99,26 @@ func (b *McpTool) Name() string {
}
func (b *McpTool) Info() tools.ToolInfo {
- required := b.tool.InputSchema.Required
- if required == nil {
- required = make([]string, 0)
- }
- parameters := b.tool.InputSchema.Properties
- if parameters == nil {
- parameters = make(map[string]any)
+ parameters := make(map[string]any)
+ required := make([]string, 0)
+
+ if input, ok := b.tool.InputSchema.(map[string]any); ok {
+ if props, ok := input["properties"].(map[string]any); ok {
+ parameters = props
+ }
+ if req, ok := input["required"].([]any); ok {
+ // Convert []any -> []string when elements are strings
+ for _, v := range req {
+ if s, ok := v.(string); ok {
+ required = append(required, s)
+ }
+ }
+ } else if reqStr, ok := input["required"].([]string); ok {
+ // Handle case where it's already []string
+ required = reqStr
+ }
}
+
return tools.ToolInfo{
Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
Description: b.tool.Description,
@@ -122,11 +137,9 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To
if err != nil {
return tools.NewTextErrorResponse(err.Error()), nil
}
- result, err := c.CallTool(ctx, mcp.CallToolRequest{
- Params: mcp.CallToolParams{
- Name: toolName,
- Arguments: args,
- },
+ result, err := c.CallTool(ctx, &mcp.CallToolParams{
+ Name: toolName,
+ Arguments: args,
})
if err != nil {
return tools.NewTextErrorResponse(err.Error()), nil
@@ -134,8 +147,8 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To
output := make([]string, 0, len(result.Content))
for _, v := range result.Content {
- if v, ok := v.(mcp.TextContent); ok {
- output = append(output, v.Text)
+ if vv, ok := v.(*mcp.TextContent); ok {
+ output = append(output, vv.Text)
} else {
output = append(output, fmt.Sprintf("%v", v))
}
@@ -143,8 +156,8 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To
return tools.NewTextResponse(strings.Join(output, "\n")), nil
}
-func getOrRenewClient(ctx context.Context, name string) (*client.Client, error) {
- c, ok := mcpClients.Get(name)
+func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) {
+ sess, ok := mcpClients.Get(name)
if !ok {
return nil, fmt.Errorf("mcp '%s' not available", name)
}
@@ -156,20 +169,20 @@ func getOrRenewClient(ctx context.Context, name string) (*client.Client, error)
timeout := mcpTimeout(m)
pingCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
- err := c.Ping(pingCtx)
+ err := sess.Ping(pingCtx, nil)
if err == nil {
- return c, nil
+ return sess, nil
}
updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, state.ToolCount)
- c, err = createAndInitializeClient(ctx, name, m, cfg.Resolver())
+ sess, err = createMCPSession(ctx, name, m, cfg.Resolver())
if err != nil {
return nil, err
}
- updateMCPState(name, MCPStateConnected, nil, c, state.ToolCount)
- mcpClients.Set(name, c)
- return c, nil
+ updateMCPState(name, MCPStateConnected, nil, sess, state.ToolCount)
+ mcpClients.Set(name, sess)
+ return sess, nil
}
func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
@@ -196,14 +209,10 @@ func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
return runTool(ctx, b.mcpName, b.tool.Name, params.Input)
}
-func getTools(ctx context.Context, name string, permissions permission.Service, c *client.Client, workingDir string) []tools.BaseTool {
- result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
+func getTools(ctx context.Context, name string, permissions permission.Service, c *mcp.ClientSession, workingDir string) ([]tools.BaseTool, error) {
+ result, err := c.ListTools(ctx, &mcp.ListToolsParams{})
if err != nil {
- slog.Error("error listing tools", "error", err)
- updateMCPState(name, MCPStateError, err, nil, 0)
- c.Close()
- mcpClients.Del(name)
- return nil
+ return nil, err
}
mcpTools := make([]tools.BaseTool, 0, len(result.Tools))
for _, tool := range result.Tools {
@@ -214,7 +223,7 @@ func getTools(ctx context.Context, name string, permissions permission.Service,
workingDir: workingDir,
})
}
- return mcpTools
+ return mcpTools, nil
}
// SubscribeMCPEvents returns a channel for MCP events
@@ -233,7 +242,7 @@ func GetMCPState(name string) (MCPClientInfo, bool) {
}
// updateMCPState updates the state of an MCP client and publishes an event
-func updateMCPState(name string, state MCPState, err error, client *client.Client, toolCount int) {
+func updateMCPState(name string, state MCPState, err error, client *mcp.ClientSession, toolCount int) {
info := MCPClientInfo{
Name: name,
State: state,
@@ -241,8 +250,12 @@ func updateMCPState(name string, state MCPState, err error, client *client.Clien
Client: client,
ToolCount: toolCount,
}
- if state == MCPStateConnected {
+ switch state {
+ case MCPStateConnected:
info.ConnectedAt = time.Now()
+ case MCPStateError:
+ updateMcpTools(name, nil)
+ mcpClients.Del(name)
}
mcpStates.Set(name, info)
@@ -260,7 +273,10 @@ func updateMCPState(name string, state MCPState, err error, client *client.Clien
func CloseMCPClients() error {
var errs []error
for name, c := range mcpClients.Seq2() {
- if err := c.Close(); err != nil {
+ if err := c.Close(); err != nil &&
+ !errors.Is(err, io.EOF) &&
+ !errors.Is(err, context.Canceled) &&
+ err.Error() != "signal: killed" {
errs = append(errs, fmt.Errorf("close mcp: %s: %w", name, err))
}
}
@@ -268,20 +284,8 @@ func CloseMCPClients() error {
return errors.Join(errs...)
}
-var mcpInitRequest = mcp.InitializeRequest{
- Params: mcp.InitializeParams{
- ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
- ClientInfo: mcp.Implementation{
- Name: "Crush",
- Version: version.Version,
- },
- },
-}
-
-func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
+func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) {
var wg sync.WaitGroup
- result := csync.NewSlice[tools.BaseTool]()
-
// Initialize states for all configured MCPs
for name, m := range cfg.MCP {
if m.Disabled {
@@ -314,61 +318,119 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
defer cancel()
- c, err := createAndInitializeClient(ctx, name, m, cfg.Resolver())
+
+ c, err := createMCPSession(ctx, name, m, cfg.Resolver())
if err != nil {
return
}
+
mcpClients.Set(name, c)
- tools := getTools(ctx, name, permissions, c, cfg.WorkingDir())
+ tools, err := getTools(ctx, name, permissions, c, cfg.WorkingDir())
+ if err != nil {
+ slog.Error("error listing tools", "error", err)
+ updateMCPState(name, MCPStateError, err, nil, 0)
+ c.Close()
+ return
+ }
+
+ updateMcpTools(name, tools)
+ mcpClients.Set(name, c)
updateMCPState(name, MCPStateConnected, nil, c, len(tools))
- result.Append(tools...)
}(name, m)
}
wg.Wait()
- return slices.Collect(result.Seq())
}
-func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
- c, err := createMcpClient(name, m, resolver)
+// updateMcpTools updates the global mcpTools and mcpClient2Tools maps
+func updateMcpTools(mcpName string, tools []tools.BaseTool) {
+ if len(tools) == 0 {
+ mcpClient2Tools.Del(mcpName)
+ } else {
+ mcpClient2Tools.Set(mcpName, tools)
+ }
+ for _, tools := range mcpClient2Tools.Seq2() {
+ for _, t := range tools {
+ mcpTools.Set(t.Name(), t)
+ }
+ }
+}
+
+func createMCPSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*mcp.ClientSession, error) {
+ timeout := mcpTimeout(m)
+ mcpCtx, cancel := context.WithCancel(ctx)
+ cancelTimer := time.AfterFunc(timeout, cancel)
+
+ transport, err := createMCPTransport(mcpCtx, m, resolver)
if err != nil {
updateMCPState(name, MCPStateError, err, nil, 0)
slog.Error("error creating mcp client", "error", err, "name", name)
+ cancel()
+ cancelTimer.Stop()
return nil, err
}
- timeout := mcpTimeout(m)
- initCtx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
+ client := mcp.NewClient(
+ &mcp.Implementation{
+ Name: "crush",
+ Version: version.Version,
+ Title: "Crush",
+ },
+ &mcp.ClientOptions{
+ ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {
+ mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
+ Type: MCPEventToolsListChanged,
+ Name: name,
+ })
+ },
+ KeepAlive: time.Minute * 10,
+ },
+ )
- // Only call Start() for non-stdio clients, as stdio clients auto-start
- if m.Type != config.MCPStdio {
- if err := c.Start(initCtx); err != nil {
- updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
- slog.Error("error starting mcp client", "error", err, "name", name)
- _ = c.Close()
- return nil, err
- }
- }
- if _, err := c.Initialize(initCtx, mcpInitRequest); err != nil {
+ session, err := client.Connect(mcpCtx, transport, nil)
+ if err != nil {
+ err = maybeStdioErr(err, transport)
updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
- slog.Error("error initializing mcp client", "error", err, "name", name)
- _ = c.Close()
+ slog.Error("error starting mcp client", "error", err, "name", name)
+ cancel()
+ cancelTimer.Stop()
return nil, err
}
+ cancelTimer.Stop()
slog.Info("Initialized mcp client", "name", name)
- return c, nil
+ return session, nil
+}
+
+// maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail
+// to parse, and the cli will then close it, causing the EOF error.
+// so, if we got an EOF err, and the transport is STDIO, we try to exec it
+// again with a timeout and collect the output so we can add details to the
+// error.
+// this happens particularly when starting things with npx, e.g. if node can't
+// be found or some other error like that.
+func maybeStdioErr(err error, transport mcp.Transport) error {
+ if !errors.Is(err, io.EOF) {
+ return err
+ }
+ ct, ok := transport.(*mcp.CommandTransport)
+ if !ok {
+ return err
+ }
+ if err2 := stdioMCPCheck(ct.Command); err2 != nil {
+ err = errors.Join(err, err2)
+ }
+ return err
}
func maybeTimeoutErr(err error, timeout time.Duration) error {
- if errors.Is(err, context.DeadlineExceeded) {
+ if errors.Is(err, context.Canceled) {
return fmt.Errorf("timed out after %s", timeout)
}
return err
}
-func createMcpClient(name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
+func createMCPTransport(ctx context.Context, m config.MCPConfig, resolver config.VariableResolver) (mcp.Transport, error) {
switch m.Type {
case config.MCPStdio:
command, err := resolver.ResolveValue(m.Command)
@@ -378,46 +440,65 @@ func createMcpClient(name string, m config.MCPConfig, resolver config.VariableRe
if strings.TrimSpace(command) == "" {
return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
}
- return client.NewStdioMCPClientWithOptions(
- home.Long(command),
- m.ResolvedEnv(),
- m.Args,
- transport.WithCommandLogger(mcpLogger{name: name}),
- )
+ cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
+ cmd.Env = append(os.Environ(), m.ResolvedEnv()...)
+ return &mcp.CommandTransport{
+ Command: cmd,
+ }, nil
case config.MCPHttp:
if strings.TrimSpace(m.URL) == "" {
return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
}
- return client.NewStreamableHttpClient(
- m.URL,
- transport.WithHTTPHeaders(m.ResolvedHeaders()),
- transport.WithHTTPLogger(mcpLogger{name: name}),
- )
- case config.MCPSse:
+ client := &http.Client{
+ Transport: &headerRoundTripper{
+ headers: m.ResolvedHeaders(),
+ },
+ }
+ return &mcp.StreamableClientTransport{
+ Endpoint: m.URL,
+ HTTPClient: client,
+ }, nil
+ case config.MCPSSE:
if strings.TrimSpace(m.URL) == "" {
return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
}
- return client.NewSSEMCPClient(
- m.URL,
- client.WithHeaders(m.ResolvedHeaders()),
- transport.WithSSELogger(mcpLogger{name: name}),
- )
+ client := &http.Client{
+ Transport: &headerRoundTripper{
+ headers: m.ResolvedHeaders(),
+ },
+ }
+ return &mcp.SSEClientTransport{
+ Endpoint: m.URL,
+ HTTPClient: client,
+ }, nil
default:
return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
}
}
-// for MCP's clients.
-type mcpLogger struct{ name string }
-
-func (l mcpLogger) Errorf(format string, v ...any) {
- slog.Error(fmt.Sprintf(format, v...), "name", l.name)
+type headerRoundTripper struct {
+ headers map[string]string
}
-func (l mcpLogger) Infof(format string, v ...any) {
- slog.Info(fmt.Sprintf(format, v...), "name", l.name)
+func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ for k, v := range rt.headers {
+ req.Header.Set(k, v)
+ }
+ return http.DefaultTransport.RoundTrip(req)
}
func mcpTimeout(m config.MCPConfig) time.Duration {
return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second
}
+
+func stdioMCPCheck(old *exec.Cmd) error {
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, old.Path, old.Args...)
+ cmd.Env = old.Env
+ out, err := cmd.CombinedOutput()
+ if err == nil || errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ return nil
+ }
+ return fmt.Errorf("%w: %s", err, string(out))
+}
@@ -53,7 +53,7 @@ func getEnvironmentInfo() string {
isGit := isGitRepo(cwd)
platform := runtime.GOOS
date := time.Now().Format("1/2/2006")
- output, _ := tools.ListDirectoryTree(cwd, nil)
+ output, _, _ := tools.ListDirectoryTree(cwd, tools.LSParams{})
return fmt.Sprintf(`Here is useful information about the environment you are running in:
<env>
Working directory: %s
@@ -13,10 +13,10 @@ import (
"strings"
"time"
- "github.com/anthropics/anthropic-sdk-go"
- "github.com/anthropics/anthropic-sdk-go/bedrock"
- "github.com/anthropics/anthropic-sdk-go/option"
- "github.com/anthropics/anthropic-sdk-go/vertex"
+ "github.com/charmbracelet/anthropic-sdk-go"
+ "github.com/charmbracelet/anthropic-sdk-go/bedrock"
+ "github.com/charmbracelet/anthropic-sdk-go/option"
+ "github.com/charmbracelet/anthropic-sdk-go/vertex"
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/llm/tools"
@@ -43,9 +43,14 @@ func createGeminiClient(opts providerClientOptions) (*genai.Client, error) {
cc := &genai.ClientConfig{
APIKey: opts.apiKey,
Backend: genai.BackendGeminiAPI,
- HTTPOptions: genai.HTTPOptions{
- BaseURL: opts.baseURL,
- },
+ }
+ if opts.baseURL != "" {
+ resolvedBaseURL, err := config.Get().Resolve(opts.baseURL)
+ if err == nil && resolvedBaseURL != "" {
+ cc.HTTPOptions = genai.HTTPOptions{
+ BaseURL: resolvedBaseURL,
+ }
+ }
}
if config.Get().Options.Debug {
cc.HTTPClient = log.NewHTTPClient()
@@ -65,9 +70,8 @@ func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Cont
var parts []*genai.Part
parts = append(parts, &genai.Part{Text: msg.Content().String()})
for _, binaryContent := range msg.BinaryContent() {
- imageFormat := strings.Split(binaryContent.MIMEType, "/")
parts = append(parts, &genai.Part{InlineData: &genai.Blob{
- MIMEType: imageFormat[1],
+ MIMEType: binaryContent.MIMEType,
Data: binaryContent.Data,
}})
}
@@ -529,11 +529,19 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error)
return true, 0, nil
}
- if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != http.StatusInternalServerError {
+ if apiErr.StatusCode == http.StatusTooManyRequests {
+ // Check if this is an insufficient quota error (permanent)
+ if apiErr.Type == "insufficient_quota" || apiErr.Code == "insufficient_quota" {
+ return false, 0, fmt.Errorf("OpenAI quota exceeded: %s. Please check your plan and billing details", apiErr.Message)
+ }
+ // Other 429 errors (rate limiting) can be retried
+ } else if apiErr.StatusCode != http.StatusInternalServerError {
return false, 0, err
}
- retryAfterValues = apiErr.Response.Header.Values("Retry-After")
+ if apiErr.Response != nil {
+ retryAfterValues = apiErr.Response.Header.Values("Retry-After")
+ }
}
if apiErr != nil {
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"os"
+ "strings"
"testing"
"time"
@@ -88,3 +89,78 @@ func TestOpenAIClientStreamChoices(t *testing.T) {
}
}
}
+
+func TestOpenAIClient429InsufficientQuotaError(t *testing.T) {
+ client := &openaiClient{
+ providerOptions: providerClientOptions{
+ modelType: config.SelectedModelTypeLarge,
+ apiKey: "test-key",
+ systemMessage: "test",
+ config: config.ProviderConfig{
+ ID: "test-openai",
+ APIKey: "test-key",
+ },
+ model: func(config.SelectedModelType) catwalk.Model {
+ return catwalk.Model{
+ ID: "test-model",
+ Name: "test-model",
+ }
+ },
+ },
+ }
+
+ // Test insufficient_quota error should not retry
+ apiErr := &openai.Error{
+ StatusCode: 429,
+ Message: "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.",
+ Type: "insufficient_quota",
+ Code: "insufficient_quota",
+ }
+
+ retry, _, err := client.shouldRetry(1, apiErr)
+ if retry {
+ t.Error("Expected shouldRetry to return false for insufficient_quota error, but got true")
+ }
+ if err == nil {
+ t.Error("Expected shouldRetry to return an error for insufficient_quota, but got nil")
+ }
+ if err != nil && !strings.Contains(err.Error(), "quota") {
+ t.Errorf("Expected error message to mention quota, got: %v", err)
+ }
+}
+
+func TestOpenAIClient429RateLimitError(t *testing.T) {
+ client := &openaiClient{
+ providerOptions: providerClientOptions{
+ modelType: config.SelectedModelTypeLarge,
+ apiKey: "test-key",
+ systemMessage: "test",
+ config: config.ProviderConfig{
+ ID: "test-openai",
+ APIKey: "test-key",
+ },
+ model: func(config.SelectedModelType) catwalk.Model {
+ return catwalk.Model{
+ ID: "test-model",
+ Name: "test-model",
+ }
+ },
+ },
+ }
+
+ // Test regular rate limit error should retry
+ apiErr := &openai.Error{
+ StatusCode: 429,
+ Message: "Rate limit reached for requests",
+ Type: "rate_limit_exceeded",
+ Code: "rate_limit_exceeded",
+ }
+
+ retry, _, err := client.shouldRetry(1, apiErr)
+ if !retry {
+ t.Error("Expected shouldRetry to return true for rate_limit_exceeded error, but got false")
+ }
+ if err != nil {
+ t.Errorf("Expected shouldRetry to return nil error for rate_limit_exceeded, but got: %v", err)
+ }
+}
@@ -30,7 +30,7 @@ func newVertexAIClient(opts providerClientOptions) VertexAIClient {
}
model := opts.model(opts.modelType)
- if strings.Contains(model.ID, "anthropic") || strings.Contains(model.ID, "claude-sonnet") {
+ if strings.Contains(model.ID, "anthropic") || strings.Contains(model.ID, "claude") || strings.Contains(model.ID, "sonnet") {
return newAnthropicClient(opts, AnthropicClientTypeVertex)
}
return &geminiClient{
@@ -23,7 +23,7 @@ type diagnosticsTool struct {
lspClients *csync.Map[string, *lsp.Client]
}
-const DiagnosticsToolName = "diagnostics"
+const DiagnosticsToolName = "lsp_diagnostics"
//go:embed diagnostics.md
var diagnosticsDescription []byte
@@ -122,7 +122,7 @@ func getDiagnostics(filePath string, lsps *csync.Map[string, *lsp.Client]) strin
}
out := output.String()
- slog.Info("Diagnostics", "output", fmt.Sprintf("%q", out))
+ slog.Info("Diagnostics", "output", out)
return out
}
@@ -2,17 +2,18 @@ package tools
import (
"bufio"
+ "bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
+ "net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
- "strconv"
"strings"
"sync"
"time"
@@ -81,6 +82,7 @@ type grepMatch struct {
path string
modTime time.Time
lineNum int
+ charNum int
lineText string
}
@@ -188,7 +190,11 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
fmt.Fprintf(&output, "%s:\n", match.path)
}
if match.lineNum > 0 {
- fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText)
+ if match.charNum > 0 {
+ fmt.Fprintf(&output, " Line %d, Char %d: %s\n", match.lineNum, match.charNum, match.lineText)
+ } else {
+ fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText)
+ }
} else {
fmt.Fprintf(&output, " %s\n", match.path)
}
@@ -251,66 +257,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
return nil, err
}
- lines := strings.Split(strings.TrimSpace(string(output)), "\n")
- matches := make([]grepMatch, 0, len(lines))
-
- for _, line := range lines {
- if line == "" {
+ var matches []grepMatch
+ for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
+ if len(line) == 0 {
continue
}
-
- // Parse ripgrep output using null separation
- filePath, lineNumStr, lineText, ok := parseRipgrepLine(line)
- if !ok {
+ var match ripgrepMatch
+ if err := json.Unmarshal(line, &match); err != nil {
continue
}
-
- lineNum, err := strconv.Atoi(lineNumStr)
- if err != nil {
+ if match.Type != "match" {
continue
}
-
- fileInfo, err := os.Stat(filePath)
- if err != nil {
- continue // Skip files we can't access
+ for _, m := range match.Data.Submatches {
+ fi, err := os.Stat(match.Data.Path.Text)
+ if err != nil {
+ continue // Skip files we can't access
+ }
+ matches = append(matches, grepMatch{
+ path: match.Data.Path.Text,
+ modTime: fi.ModTime(),
+ lineNum: match.Data.LineNumber,
+ charNum: m.Start + 1, // ensure 1-based
+ lineText: strings.TrimSpace(match.Data.Lines.Text),
+ })
+ // only get the first match of each line
+ break
}
-
- matches = append(matches, grepMatch{
- path: filePath,
- modTime: fileInfo.ModTime(),
- lineNum: lineNum,
- lineText: lineText,
- })
}
-
return matches, nil
}
-// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths
-func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) {
- // Split on null byte first to separate filename from rest
- parts := strings.SplitN(line, "\x00", 2)
- if len(parts) != 2 {
- return "", "", "", false
- }
-
- filePath = parts[0]
- remainder := parts[1]
-
- // Now split the remainder on first colon: "linenum:content"
- colonIndex := strings.Index(remainder, ":")
- if colonIndex == -1 {
- return "", "", "", false
- }
-
- lineNumStr := remainder[:colonIndex]
- lineText = remainder[colonIndex+1:]
-
- if _, err := strconv.Atoi(lineNumStr); err != nil {
- return "", "", "", false
- }
-
- return filePath, lineNumStr, lineText, true
+type ripgrepMatch struct {
+ Type string `json:"type"`
+ Data struct {
+ Path struct {
+ Text string `json:"text"`
+ } `json:"path"`
+ Lines struct {
+ Text string `json:"text"`
+ } `json:"lines"`
+ LineNumber int `json:"line_number"`
+ Submatches []struct {
+ Start int `json:"start"`
+ } `json:"submatches"`
+ } `json:"data"`
}
func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
@@ -362,7 +353,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
return nil
}
- match, lineNum, lineText, err := fileContainsPattern(path, regex)
+ match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex)
if err != nil {
return nil // Skip files we can't read
}
@@ -372,6 +363,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
path: path,
modTime: info.ModTime(),
lineNum: lineNum,
+ charNum: charNum,
lineText: lineText,
})
@@ -389,15 +381,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
return matches, nil
}
-func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
- // Quick binary file detection
- if isBinaryFile(filePath) {
- return false, 0, "", nil
+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, int, string, error) {
+ // Only search text files.
+ if !isTextFile(filePath) {
+ return false, 0, 0, "", nil
}
file, err := os.Open(filePath)
if err != nil {
- return false, 0, "", err
+ return false, 0, 0, "", err
}
defer file.Close()
@@ -406,53 +398,39 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st
for scanner.Scan() {
lineNum++
line := scanner.Text()
- if pattern.MatchString(line) {
- return true, lineNum, line, nil
+ if loc := pattern.FindStringIndex(line); loc != nil {
+ charNum := loc[0] + 1
+ return true, lineNum, charNum, line, nil
}
}
- return false, 0, "", scanner.Err()
+ return false, 0, 0, "", scanner.Err()
}
-var binaryExts = map[string]struct{}{
- ".exe": {}, ".dll": {}, ".so": {}, ".dylib": {},
- ".bin": {}, ".obj": {}, ".o": {}, ".a": {},
- ".zip": {}, ".tar": {}, ".gz": {}, ".bz2": {},
- ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {},
- ".pdf": {}, ".doc": {}, ".docx": {}, ".xls": {},
- ".mp3": {}, ".mp4": {}, ".avi": {}, ".mov": {},
-}
-
-// isBinaryFile performs a quick check to determine if a file is binary
-func isBinaryFile(filePath string) bool {
- // Check file extension first (fastest)
- ext := strings.ToLower(filepath.Ext(filePath))
- if _, isBinary := binaryExts[ext]; isBinary {
- return true
- }
-
- // Quick content check for files without clear extensions
+// isTextFile checks if a file is a text file by examining its MIME type.
+func isTextFile(filePath string) bool {
file, err := os.Open(filePath)
if err != nil {
- return false // If we can't open it, let the caller handle the error
+ return false
}
defer file.Close()
- // Read first 512 bytes to check for null bytes
+ // Read first 512 bytes for MIME type detection.
buffer := make([]byte, 512)
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
return false
}
- // Check for null bytes (common in binary files)
- for i := range n {
- if buffer[i] == 0 {
- return true
- }
- }
+ // Detect content type.
+ contentType := http.DetectContentType(buffer[:n])
- return false
+ // Check if it's a text MIME type.
+ return strings.HasPrefix(contentType, "text/") ||
+ contentType == "application/json" ||
+ contentType == "application/xml" ||
+ contentType == "application/javascript" ||
+ contentType == "application/x-sh"
}
func globToRegex(glob string) string {
@@ -198,3 +198,224 @@ func BenchmarkRegexCacheVsCompile(b *testing.B) {
}
})
}
+
+func TestIsTextFile(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+
+ tests := []struct {
+ name string
+ filename string
+ content []byte
+ wantText bool
+ }{
+ {
+ name: "go file",
+ filename: "test.go",
+ content: []byte("package main\n\nfunc main() {}\n"),
+ wantText: true,
+ },
+ {
+ name: "yaml file",
+ filename: "config.yaml",
+ content: []byte("key: value\nlist:\n - item1\n - item2\n"),
+ wantText: true,
+ },
+ {
+ name: "yml file",
+ filename: "config.yml",
+ content: []byte("key: value\n"),
+ wantText: true,
+ },
+ {
+ name: "json file",
+ filename: "data.json",
+ content: []byte(`{"key": "value"}`),
+ wantText: true,
+ },
+ {
+ name: "javascript file",
+ filename: "script.js",
+ content: []byte("console.log('hello');\n"),
+ wantText: true,
+ },
+ {
+ name: "typescript file",
+ filename: "script.ts",
+ content: []byte("const x: string = 'hello';\n"),
+ wantText: true,
+ },
+ {
+ name: "markdown file",
+ filename: "README.md",
+ content: []byte("# Title\n\nSome content\n"),
+ wantText: true,
+ },
+ {
+ name: "shell script",
+ filename: "script.sh",
+ content: []byte("#!/bin/bash\necho 'hello'\n"),
+ wantText: true,
+ },
+ {
+ name: "python file",
+ filename: "script.py",
+ content: []byte("print('hello')\n"),
+ wantText: true,
+ },
+ {
+ name: "xml file",
+ filename: "data.xml",
+ content: []byte("<?xml version=\"1.0\"?>\n<root></root>\n"),
+ wantText: true,
+ },
+ {
+ name: "plain text",
+ filename: "file.txt",
+ content: []byte("plain text content\n"),
+ wantText: true,
+ },
+ {
+ name: "css file",
+ filename: "style.css",
+ content: []byte("body { color: red; }\n"),
+ wantText: true,
+ },
+ {
+ name: "scss file",
+ filename: "style.scss",
+ content: []byte("$primary: blue;\nbody { color: $primary; }\n"),
+ wantText: true,
+ },
+ {
+ name: "sass file",
+ filename: "style.sass",
+ content: []byte("$primary: blue\nbody\n color: $primary\n"),
+ wantText: true,
+ },
+ {
+ name: "rust file",
+ filename: "main.rs",
+ content: []byte("fn main() {\n println!(\"Hello, world!\");\n}\n"),
+ wantText: true,
+ },
+ {
+ name: "zig file",
+ filename: "main.zig",
+ content: []byte("const std = @import(\"std\");\npub fn main() void {}\n"),
+ wantText: true,
+ },
+ {
+ name: "java file",
+ filename: "Main.java",
+ content: []byte("public class Main {\n public static void main(String[] args) {}\n}\n"),
+ wantText: true,
+ },
+ {
+ name: "c file",
+ filename: "main.c",
+ content: []byte("#include <stdio.h>\nint main() { return 0; }\n"),
+ wantText: true,
+ },
+ {
+ name: "cpp file",
+ filename: "main.cpp",
+ content: []byte("#include <iostream>\nint main() { return 0; }\n"),
+ wantText: true,
+ },
+ {
+ name: "fish shell",
+ filename: "script.fish",
+ content: []byte("#!/usr/bin/env fish\necho 'hello'\n"),
+ wantText: true,
+ },
+ {
+ name: "powershell file",
+ filename: "script.ps1",
+ content: []byte("Write-Host 'Hello, World!'\n"),
+ wantText: true,
+ },
+ {
+ name: "cmd batch file",
+ filename: "script.bat",
+ content: []byte("@echo off\necho Hello, World!\n"),
+ wantText: true,
+ },
+ {
+ name: "cmd file",
+ filename: "script.cmd",
+ content: []byte("@echo off\necho Hello, World!\n"),
+ wantText: true,
+ },
+ {
+ name: "binary exe",
+ filename: "binary.exe",
+ content: []byte{0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00},
+ wantText: false,
+ },
+ {
+ name: "png image",
+ filename: "image.png",
+ content: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
+ wantText: false,
+ },
+ {
+ name: "jpeg image",
+ filename: "image.jpg",
+ content: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46},
+ wantText: false,
+ },
+ {
+ name: "zip archive",
+ filename: "archive.zip",
+ content: []byte{0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00},
+ wantText: false,
+ },
+ {
+ name: "pdf file",
+ filename: "document.pdf",
+ content: []byte("%PDF-1.4\n%âãÏÓ\n"),
+ wantText: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ filePath := filepath.Join(tempDir, tt.filename)
+ require.NoError(t, os.WriteFile(filePath, tt.content, 0o644))
+
+ got := isTextFile(filePath)
+ require.Equal(t, tt.wantText, got, "isTextFile(%s) = %v, want %v", tt.filename, got, tt.wantText)
+ })
+ }
+}
+
+func TestColumnMatch(t *testing.T) {
+ t.Parallel()
+
+ // Test both implementations
+ for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
+ "regex": searchFilesWithRegex,
+ "rg": func(pattern, path, include string) ([]grepMatch, error) {
+ return searchWithRipgrep(t.Context(), pattern, path, include)
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+
+ if name == "rg" && getRg() == "" {
+ t.Skip("rg is not in $PATH")
+ }
+
+ matches, err := fn("THIS", "./testdata/", "")
+ require.NoError(t, err)
+ require.Len(t, matches, 1)
+ match := matches[0]
+ require.Equal(t, 2, match.lineNum)
+ require.Equal(t, 14, match.charNum)
+ require.Equal(t, "I wanna grep THIS particular word", match.lineText)
+ require.Equal(t, "testdata/grep.txt", filepath.ToSlash(filepath.Clean(match.path)))
+ })
+ }
+}
@@ -1,6 +1,7 @@
package tools
import (
+ "cmp"
"context"
_ "embed"
"encoding/json"
@@ -9,6 +10,7 @@ import (
"path/filepath"
"strings"
+ "github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/permission"
)
@@ -16,11 +18,13 @@ import (
type LSParams struct {
Path string `json:"path"`
Ignore []string `json:"ignore"`
+ Depth int `json:"depth"`
}
type LSPermissionsParams struct {
Path string `json:"path"`
Ignore []string `json:"ignore"`
+ Depth int `json:"depth"`
}
type TreeNode struct {
@@ -42,7 +46,7 @@ type lsTool struct {
const (
LSToolName = "ls"
- MaxLSFiles = 1000
+ maxLSFiles = 1000
)
//go:embed ls.md
@@ -68,6 +72,10 @@ func (l *lsTool) Info() ToolInfo {
"type": "string",
"description": "The path to the directory to list (defaults to current working directory)",
},
+ "depth": map[string]any{
+ "type": "integer",
+ "description": "The maximum depth to traverse",
+ },
"ignore": map[string]any{
"type": "array",
"description": "List of glob patterns to ignore",
@@ -86,13 +94,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
- searchPath := params.Path
- if searchPath == "" {
- searchPath = l.workingDir
- }
-
- var err error
- searchPath, err = fsext.Expand(searchPath)
+ searchPath, err := fsext.Expand(cmp.Or(params.Path, l.workingDir))
if err != nil {
return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
}
@@ -137,44 +139,49 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
}
}
- output, err := ListDirectoryTree(searchPath, params.Ignore)
+ output, metadata, err := ListDirectoryTree(searchPath, params)
if err != nil {
return ToolResponse{}, err
}
- // Get file count for metadata
- files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
- if err != nil {
- return ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
- }
-
return WithResponseMetadata(
NewTextResponse(output),
- LSResponseMetadata{
- NumberOfFiles: len(files),
- Truncated: truncated,
- },
+ metadata,
), nil
}
-func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
+func ListDirectoryTree(searchPath string, params LSParams) (string, LSResponseMetadata, error) {
if _, err := os.Stat(searchPath); os.IsNotExist(err) {
- return "", fmt.Errorf("path does not exist: %s", searchPath)
+ return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
}
- files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
+ ls := config.Get().Tools.Ls
+ depth, limit := ls.Limits()
+ maxFiles := cmp.Or(limit, maxLSFiles)
+ files, truncated, err := fsext.ListDirectory(
+ searchPath,
+ params.Ignore,
+ cmp.Or(params.Depth, depth),
+ maxFiles,
+ )
if err != nil {
- return "", fmt.Errorf("error listing directory: %w", err)
+ return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err)
}
+ metadata := LSResponseMetadata{
+ NumberOfFiles: len(files),
+ Truncated: truncated,
+ }
tree := createFileTree(files, searchPath)
- output := printTree(tree, searchPath)
+ var output string
if truncated {
- output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
+ output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %[1]d files and directories are included below.\n", maxFiles)
}
-
- return output, nil
+ if depth > 0 {
+ output = fmt.Sprintf("The directory tree is shown up to a depth of %d. Use a higher depth and a specific path to see more levels.\n", cmp.Or(params.Depth, depth))
+ }
+ return output + "\n" + printTree(tree, searchPath), metadata, nil
}
func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
@@ -0,0 +1,214 @@
+package tools
+
+import (
+ "cmp"
+ "context"
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "maps"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "sort"
+ "strings"
+
+ "github.com/charmbracelet/crush/internal/csync"
+ "github.com/charmbracelet/crush/internal/lsp"
+ "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+)
+
+type ReferencesParams struct {
+ Symbol string `json:"symbol"`
+ Path string `json:"path"`
+}
+
+type referencesTool struct {
+ lspClients *csync.Map[string, *lsp.Client]
+}
+
+const ReferencesToolName = "lsp_references"
+
+//go:embed references.md
+var referencesDescription []byte
+
+func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) BaseTool {
+ return &referencesTool{
+ lspClients,
+ }
+}
+
+func (r *referencesTool) Name() string {
+ return ReferencesToolName
+}
+
+func (r *referencesTool) Info() ToolInfo {
+ return ToolInfo{
+ Name: ReferencesToolName,
+ Description: string(referencesDescription),
+ Parameters: map[string]any{
+ "symbol": map[string]any{
+ "type": "string",
+ "description": "The symbol name to search for (e.g., function name, variable name, type name).",
+ },
+ "path": map[string]any{
+ "type": "string",
+ "description": "The directory to search in. Should be the entire project most of the time. Defaults to the current working directory.",
+ },
+ },
+ Required: []string{"symbol"},
+ }
+}
+
+func (r *referencesTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
+ var params ReferencesParams
+ if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
+ return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
+ }
+
+ if params.Symbol == "" {
+ return NewTextErrorResponse("symbol is required"), nil
+ }
+
+ if r.lspClients.Len() == 0 {
+ return NewTextErrorResponse("no LSP clients available"), nil
+ }
+
+ workingDir := cmp.Or(params.Path, ".")
+
+ matches, _, err := searchFiles(ctx, regexp.QuoteMeta(params.Symbol), workingDir, "", 100)
+ if err != nil {
+ return NewTextErrorResponse(fmt.Sprintf("failed to search for symbol: %s", err)), nil
+ }
+
+ if len(matches) == 0 {
+ return NewTextResponse(fmt.Sprintf("Symbol '%s' not found", params.Symbol)), nil
+ }
+
+ var allLocations []protocol.Location
+ var allErrs error
+ for _, match := range matches {
+ locations, err := r.find(ctx, params.Symbol, match)
+ if err != nil {
+ if strings.Contains(err.Error(), "no identifier found") {
+ // grep probably matched a comment, string value, or something else that's irrelevant
+ continue
+ }
+ slog.Error("Failed to find references", "error", err, "symbol", params.Symbol, "path", match.path, "line", match.lineNum, "char", match.charNum)
+ allErrs = errors.Join(allErrs, err)
+ continue
+ }
+ allLocations = append(allLocations, locations...)
+ // XXX: should we break here or look for all results?
+ }
+
+ if len(allLocations) > 0 {
+ output := formatReferences(cleanupLocations(allLocations))
+ return NewTextResponse(output), nil
+ }
+
+ if allErrs != nil {
+ return NewTextErrorResponse(allErrs.Error()), nil
+ }
+ return NewTextResponse(fmt.Sprintf("No references found for symbol '%s'", params.Symbol)), nil
+}
+
+func (r *referencesTool) find(ctx context.Context, symbol string, match grepMatch) ([]protocol.Location, error) {
+ absPath, err := filepath.Abs(match.path)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get absolute path: %s", err)
+ }
+
+ var client *lsp.Client
+ for c := range r.lspClients.Seq() {
+ if c.HandlesFile(absPath) {
+ client = c
+ break
+ }
+ }
+
+ if client == nil {
+ slog.Warn("No LSP clients to handle", "path", match.path)
+ return nil, nil
+ }
+
+ return client.FindReferences(
+ ctx,
+ absPath,
+ match.lineNum,
+ match.charNum+getSymbolOffset(symbol),
+ true,
+ )
+}
+
+// getSymbolOffset returns the character offset to the actual symbol name
+// in a qualified symbol (e.g., "Bar" in "foo.Bar" or "method" in "Class::method").
+func getSymbolOffset(symbol string) int {
+ // Check for :: separator (Rust, C++, Ruby modules/classes, PHP static).
+ if idx := strings.LastIndex(symbol, "::"); idx != -1 {
+ return idx + 2
+ }
+ // Check for . separator (Go, Python, JavaScript, Java, C#, Ruby methods).
+ if idx := strings.LastIndex(symbol, "."); idx != -1 {
+ return idx + 1
+ }
+ // Check for \ separator (PHP namespaces).
+ if idx := strings.LastIndex(symbol, "\\"); idx != -1 {
+ return idx + 1
+ }
+ return 0
+}
+
+func cleanupLocations(locations []protocol.Location) []protocol.Location {
+ slices.SortFunc(locations, func(a, b protocol.Location) int {
+ if a.URI != b.URI {
+ return strings.Compare(string(a.URI), string(b.URI))
+ }
+ if a.Range.Start.Line != b.Range.Start.Line {
+ return cmp.Compare(a.Range.Start.Line, b.Range.Start.Line)
+ }
+ return cmp.Compare(a.Range.Start.Character, b.Range.Start.Character)
+ })
+ return slices.CompactFunc(locations, func(a, b protocol.Location) bool {
+ return a.URI == b.URI &&
+ a.Range.Start.Line == b.Range.Start.Line &&
+ a.Range.Start.Character == b.Range.Start.Character
+ })
+}
+
+func groupByFilename(locations []protocol.Location) map[string][]protocol.Location {
+ files := make(map[string][]protocol.Location)
+ for _, loc := range locations {
+ path, err := loc.URI.Path()
+ if err != nil {
+ slog.Error("Failed to convert location URI to path", "uri", loc.URI, "error", err)
+ continue
+ }
+ files[path] = append(files[path], loc)
+ }
+ return files
+}
+
+func formatReferences(locations []protocol.Location) string {
+ fileRefs := groupByFilename(locations)
+ files := slices.Collect(maps.Keys(fileRefs))
+ sort.Strings(files)
+
+ var output strings.Builder
+ output.WriteString(fmt.Sprintf("Found %d reference(s) in %d file(s):\n\n", len(locations), len(files)))
+
+ for _, file := range files {
+ refs := fileRefs[file]
+ output.WriteString(fmt.Sprintf("%s (%d reference(s)):\n", file, len(refs)))
+ for _, ref := range refs {
+ line := ref.Range.Start.Line + 1
+ char := ref.Range.Start.Character + 1
+ output.WriteString(fmt.Sprintf(" Line %d, Column %d\n", line, char))
+ }
+ output.WriteString("\n")
+ }
+
+ return output.String()
+}
@@ -0,0 +1,36 @@
+Find all references to/usage of a symbol by name using the Language Server Protocol (LSP).
+
+WHEN TO USE THIS TOOL:
+
+- **ALWAYS USE THIS FIRST** when searching for where a function, method, variable, type, or constant is used
+- **DO NOT use grep/glob for symbol searches** - this tool is semantic-aware and much more accurate
+- Use when you need to find all usages of a specific symbol (function, variable, type, class, method, etc.)
+- More accurate than grep because it understands code semantics and scope
+- Finds only actual references, not string matches in comments or unrelated code
+- Helpful for understanding where a symbol is used throughout the codebase
+- Useful for refactoring or analyzing code dependencies
+- Good for finding all call sites of a function, method, type, package, constant, variable, etc.
+
+HOW TO USE:
+
+- Provide the symbol name (e.g., "MyFunction", "myVariable", "MyType")
+- Optionally specify a path to narrow the search to a specific directory
+- The tool will automatically find the symbol and locate all references
+
+FEATURES:
+
+- Returns all references grouped by file
+- Shows line and column numbers for each reference
+- Supports multiple programming languages through LSP
+- Automatically finds the symbol without needing exact position
+
+LIMITATIONS:
+
+- May not find references in files that haven't been opened or indexed
+- Results depend on the LSP server's capabilities
+
+TIPS:
+
+- **Use this tool instead of grep when looking for symbol references** - it's more accurate and semantic-aware
+- Simply provide the symbol name and let the tool find it for you
+- This tool understands code structure, so it won't match unrelated strings or comments
@@ -43,7 +43,7 @@ func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cm
return nil
}
// Use -n to show line numbers, -0 for null separation to handle Windows paths
- args := []string{"-H", "-n", "-0", pattern}
+ args := []string{"--json", "-H", "-n", "-0", pattern}
if include != "" {
args = append(args, "--glob", include)
}
@@ -0,0 +1,3 @@
+test file for grep
+I wanna grep THIS particular word
+and nothing else
@@ -39,12 +39,14 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro
return nil, err
}
- slog.Debug(
- "HTTP Request",
- "method", req.Method,
- "url", req.URL,
- "body", bodyToString(save),
- )
+ if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
+ slog.Debug(
+ "HTTP Request",
+ "method", req.Method,
+ "url", req.URL,
+ "body", bodyToString(save),
+ )
+ }
start := time.Now()
resp, err := h.Transport.RoundTrip(req)
@@ -61,16 +63,18 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro
}
save, resp.Body, err = drainBody(resp.Body)
- slog.Debug(
- "HTTP Response",
- "status_code", resp.StatusCode,
- "status", resp.Status,
- "headers", formatHeaders(resp.Header),
- "body", bodyToString(save),
- "content_length", resp.ContentLength,
- "duration_ms", duration.Milliseconds(),
- "error", err,
- )
+ if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
+ slog.Debug(
+ "HTTP Response",
+ "status_code", resp.StatusCode,
+ "status", resp.Status,
+ "headers", formatHeaders(resp.Header),
+ "body", bodyToString(save),
+ "content_length", resp.ContentLength,
+ "duration_ms", duration.Milliseconds(),
+ "error", err,
+ )
+ }
return resp, err
}
@@ -84,7 +88,7 @@ func bodyToString(body io.ReadCloser) string {
return ""
}
var b bytes.Buffer
- if json.Compact(&b, bytes.TrimSpace(src)) != nil {
+ if json.Indent(&b, bytes.TrimSpace(src), "", " ") != nil {
// not json probably
return string(src)
}
@@ -445,6 +445,16 @@ func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) {
}
}
+// FindReferences finds all references to the symbol at the given position.
+func (c *Client) FindReferences(ctx context.Context, filepath string, line, character int, includeDeclaration bool) ([]protocol.Location, error) {
+ if err := c.OpenFileOnDemand(ctx, filepath); err != nil {
+ return nil, err
+ }
+ // NOTE: line and character should be 0-based.
+ // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
+ return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
+}
+
// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
// Uses glob patterns to match files, allowing for more flexible matching.
func HasRootMarkers(dir string, rootMarkers []string) bool {
@@ -16,6 +16,7 @@ import (
"github.com/lucasb-eyer/go-colorful"
"github.com/charmbracelet/crush/internal/csync"
+ "github.com/charmbracelet/crush/internal/tui/util"
)
const (
@@ -318,7 +319,7 @@ func (a *Anim) Init() tea.Cmd {
}
// Update processes animation steps (or not).
-func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (a *Anim) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case StepMsg:
if msg.id != a.id {
@@ -101,7 +101,7 @@ func (m *messageListCmp) Init() tea.Cmd {
}
// Update handles incoming messages and updates the component state.
-func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
var cmds []tea.Cmd
if m.session.ID != "" && m.app.CoderAgent != nil {
queueSize := m.app.CoderAgent.QueuedPrompts(m.session.ID)
@@ -86,6 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
const (
maxAttachments = 5
+ maxFileResults = 25
)
type OpenEditorMsg struct {
@@ -171,7 +172,7 @@ func (m *editorCmp) repositionCompletions() tea.Msg {
return completions.RepositionCompletionsMsg{X: x, Y: y}
}
-func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
@@ -480,7 +481,9 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
}
func (m *editorCmp) startCompletions() tea.Msg {
- files, _, _ := fsext.ListDirectory(".", nil, 0)
+ ls := m.app.Config().Options.TUI.Completions
+ depth, limit := ls.Limits()
+ files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
slices.Sort(files)
completionItems := make([]completions.Completion, 0, len(files))
for _, file := range files {
@@ -498,6 +501,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
Completions: completionItems,
X: x,
Y: y,
+ MaxResults: maxFileResults,
}
}
@@ -44,7 +44,7 @@ func (h *header) Init() tea.Cmd {
return nil
}
-func (h *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (h *header) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case pubsub.Event[session.Session]:
if msg.Type == pubsub.UpdatedEvent {
@@ -12,6 +12,7 @@ import (
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
+ "github.com/charmbracelet/x/exp/ordered"
"github.com/google/uuid"
"github.com/atotto/clipboard"
@@ -34,7 +35,7 @@ var ClearSelectionKey = key.NewBinding(key.WithKeys("esc", "alt+esc"), key.WithH
// MessageCmp defines the interface for message components in the chat interface.
// It combines standard UI model interfaces with message-specific functionality.
type MessageCmp interface {
- util.Model // Basic Bubble Tea model interface
+ util.Model // Basic Bubble util.Model interface
layout.Sizeable // Width/height management
layout.Focusable // Focus state management
GetMessage() message.Message // Access to underlying message data
@@ -93,7 +94,7 @@ func (m *messageCmp) Init() tea.Cmd {
// Update handles incoming messages and updates the component state.
// Manages animation updates for spinning messages and stops animation when appropriate.
-func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *messageCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case anim.StepMsg:
m.spinning = m.shouldSpin()
@@ -271,7 +272,7 @@ func (m *messageCmp) renderThinkingContent() string {
}
}
fullContent := content.String()
- height := util.Clamp(lipgloss.Height(fullContent), 1, 10)
+ height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10)
m.thinkingViewport.SetHeight(height)
m.thinkingViewport.SetWidth(m.textWidth())
m.thinkingViewport.SetContent(fullContent)
@@ -344,7 +345,7 @@ func (m *messageCmp) GetSize() (int, int) {
// SetSize updates the width of the message component for text wrapping
func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
- m.width = util.Clamp(width, 1, 120)
+ m.width = ordered.Clamp(width, 1, 120)
m.thinkingViewport.SetWidth(m.width - 4)
return nil
}
@@ -383,7 +384,7 @@ func (m *assistantSectionModel) Init() tea.Cmd {
return nil
}
-func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (m *assistantSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
return m, nil
}
@@ -27,7 +27,7 @@ import (
// ToolCallCmp defines the interface for tool call components in the chat interface.
// It manages the display of tool execution including pending states, results, and errors.
type ToolCallCmp interface {
- util.Model // Basic Bubble Tea model interface
+ util.Model // Basic Bubble util.Model interface
layout.Sizeable // Width/height management
layout.Focusable // Focus state management
GetToolCall() message.ToolCall // Access to tool call data
@@ -147,7 +147,7 @@ func (m *toolCallCmp) Init() tea.Cmd {
// Update handles incoming messages and updates the component state.
// Manages animation updates for pending tool calls.
-func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *toolCallCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case anim.StepMsg:
var cmds []tea.Cmd
@@ -160,7 +160,7 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if m.spinning {
u, cmd := m.anim.Update(msg)
- m.anim = u.(util.Model)
+ m.anim = u
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
@@ -88,7 +88,7 @@ func (m *sidebarCmp) Init() tea.Cmd {
return nil
}
-func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *sidebarCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case SessionFilesMsg:
m.files = csync.NewMap[string, SessionFile]()
@@ -135,7 +135,7 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
}
// Update implements SplashPage.
-func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return s, s.SetSize(msg.Width, msg.Height)
@@ -22,6 +22,7 @@ type OpenCompletionsMsg struct {
Completions []Completion
X int // X position for the completions popup
Y int // Y position for the completions popup
+ MaxResults int // Maximum number of results to render, 0 for no limit
}
type FilterCompletionsMsg struct {
@@ -111,7 +112,7 @@ func (c *completionsCmp) Init() tea.Cmd {
}
// Update implements Completions.
-func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *completionsCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
c.wWidth, c.wHeight = msg.Width, msg.Height
@@ -192,6 +193,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
c.width = width
c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
+ c.list.SetResultsSize(msg.MaxResults)
return c, tea.Batch(
c.list.SetItems(items),
c.list.SetSize(c.width, c.height),
@@ -110,14 +110,17 @@ func Status(opts StatusOpts, width int) string {
extraContentWidth += 1
}
description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…")
+ description = t.S().Base.Foreground(descriptionColor).Render(description)
}
- description = t.S().Base.Foreground(descriptionColor).Render(description)
content := []string{}
if icon != "" {
content = append(content, icon)
}
- content = append(content, title, description)
+ content = append(content, title)
+ if description != "" {
+ content = append(content, description)
+ }
if opts.ExtraContent != "" {
content = append(content, opts.ExtraContent)
}
@@ -36,7 +36,7 @@ func (m *statusCmp) Init() tea.Cmd {
return nil
}
-func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *statusCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
@@ -1 +1 @@
-● [38;2;133;131;146mTitle Only[m [38;2;96;95;107m[m
+● [38;2;133;131;146mTitle Only[m
@@ -92,7 +92,7 @@ func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
}
// Update implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
c.wWidth = msg.Width
@@ -128,12 +128,17 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.inputs[c.focusIndex].Blur()
c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
c.inputs[c.focusIndex].Focus()
-
+ case key.Matches(msg, c.keys.Close):
+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
default:
var cmd tea.Cmd
c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
return c, cmd
}
+ case tea.PasteMsg:
+ var cmd tea.Cmd
+ c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
+ return c, cmd
}
return c, nil
}
@@ -116,7 +116,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
return c.SetCommandType(c.commandType)
}
-func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
c.wWidth = msg.Width
@@ -76,6 +76,7 @@ type ArgumentsDialogKeyMap struct {
Confirm key.Binding
Next key.Binding
Previous key.Binding
+ Close key.Binding
}
func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
@@ -93,6 +94,10 @@ func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
key.WithKeys("shift+tab", "up"),
key.WithHelp("shift+tab/↑", "previous"),
),
+ Close: key.NewBinding(
+ key.WithKeys("esc", "alt+esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
}
}
@@ -102,6 +107,7 @@ func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding {
k.Confirm,
k.Next,
k.Previous,
+ k.Close,
}
}
@@ -122,5 +128,6 @@ func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding {
k.Confirm,
k.Next,
k.Previous,
+ k.Close,
}
}
@@ -61,7 +61,7 @@ func (c *compactDialogCmp) Init() tea.Cmd {
return nil
}
-func (c *compactDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *compactDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
c.wWidth = msg.Width
@@ -32,7 +32,7 @@ type CloseDialogMsg struct{}
// DialogCmp manages a stack of dialogs with keyboard navigation.
type DialogCmp interface {
- tea.Model
+ util.Model
Dialogs() []DialogModel
HasDialogs() bool
@@ -62,7 +62,7 @@ func (d dialogCmp) Init() tea.Cmd {
}
// Update handles dialog lifecycle and forwards messages to the active dialog.
-func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (d dialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
var cmds []tea.Cmd
@@ -98,7 +98,11 @@ func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return d, nil
}
-func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
+func (d dialogCmp) View() string {
+ return ""
+}
+
+func (d dialogCmp) handleOpen(msg OpenDialogMsg) (util.Model, tea.Cmd) {
if d.HasDialogs() {
dialog := d.dialogs[len(d.dialogs)-1]
if dialog.ID() == msg.Model.ID() {
@@ -88,7 +88,7 @@ func (m *model) Init() tea.Cmd {
return m.filePicker.Init()
}
-func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *model) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.wWidth = msg.Width
@@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/home"
"github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
)
@@ -75,7 +76,7 @@ func (a *APIKeyInput) Init() tea.Cmd {
return a.spinner.Tick
}
-func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (a *APIKeyInput) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
if a.state == APIKeyInputStateVerifying {
@@ -19,7 +19,7 @@ func DefaultKeyMap() KeyMap {
return KeyMap{
Select: key.NewBinding(
key.WithKeys("enter", "ctrl+y"),
- key.WithHelp("enter", "confirm"),
+ key.WithHelp("enter", "choose"),
),
Next: key.NewBinding(
key.WithKeys("down", "ctrl+n"),
@@ -35,7 +35,7 @@ func DefaultKeyMap() KeyMap {
),
Close: key.NewBinding(
key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "cancel"),
+ key.WithHelp("esc", "exit"),
),
}
}
@@ -98,7 +98,7 @@ func (m *modelDialogCmp) Init() tea.Cmd {
return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
}
-func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.wWidth = msg.Width
@@ -42,7 +42,7 @@ func DefaultKeyMap() KeyMap {
key.WithHelp("s", "allow session"),
),
Deny: key.NewBinding(
- key.WithKeys("d", "D", "ctrl+d", "esc"),
+ key.WithKeys("d", "D", "esc"),
key.WithHelp("d", "deny"),
),
Select: key.NewBinding(
@@ -95,7 +95,7 @@ func (p *permissionDialogCmp) supportsDiffView() bool {
return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName || p.permission.ToolName == tools.MultiEditToolName
}
-func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
@@ -40,7 +40,7 @@ func (q *quitDialogCmp) Init() tea.Cmd {
}
// Update handles keyboard input for the quit dialog.
-func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (q *quitDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
q.wWidth = msg.Width
@@ -172,7 +172,7 @@ func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
return nil
}
-func (r *reasoningDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (r *reasoningDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
r.wWidth = msg.Width
@@ -15,7 +15,7 @@ func DefaultKeyMap() KeyMap {
return KeyMap{
Select: key.NewBinding(
key.WithKeys("enter", "tab", "ctrl+y"),
- key.WithHelp("enter", "confirm"),
+ key.WithHelp("enter", "choose"),
),
Next: key.NewBinding(
key.WithKeys("down", "ctrl+n"),
@@ -27,7 +27,7 @@ func DefaultKeyMap() KeyMap {
),
Close: key.NewBinding(
key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "cancel"),
+ key.WithHelp("esc", "exit"),
),
}
}
@@ -81,7 +81,7 @@ func (s *sessionDialogCmp) Init() tea.Cmd {
return tea.Sequence(cmds...)
}
-func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *sessionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
var cmds []tea.Cmd
@@ -56,32 +56,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
break
}
- // Determine icon color and description based on state
- icon := t.ItemOfflineIcon
- description := l.LSP.Command
-
- if l.LSP.Disabled {
- description = t.S().Subtle.Render("disabled")
- } else if state, exists := lspStates[l.Name]; exists {
- switch state.State {
- case lsp.StateStarting:
- icon = t.ItemBusyIcon
- description = t.S().Subtle.Render("starting...")
- case lsp.StateReady:
- icon = t.ItemOnlineIcon
- description = l.LSP.Command
- case lsp.StateError:
- icon = t.ItemErrorIcon
- if state.Error != nil {
- description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
- } else {
- description = t.S().Subtle.Render("error")
- }
- case lsp.StateDisabled:
- icon = t.ItemOfflineIcon.Foreground(t.FgMuted)
- description = t.S().Base.Foreground(t.FgMuted).Render("no root markers found")
- }
- }
+ icon, description := iconAndDescription(l, t, lspStates)
// Calculate diagnostic counts if we have LSP clients
var extraContent string
@@ -134,6 +109,30 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
return lspList
}
+func iconAndDescription(l config.LSP, t *styles.Theme, states map[string]app.LSPClientInfo) (lipgloss.Style, string) {
+ if l.LSP.Disabled {
+ return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("disabled")
+ }
+
+ info := states[l.Name]
+ switch info.State {
+ case lsp.StateStarting:
+ return t.ItemBusyIcon, t.S().Subtle.Render("starting...")
+ case lsp.StateReady:
+ return t.ItemOnlineIcon, ""
+ case lsp.StateError:
+ description := t.S().Subtle.Render("error")
+ if info.Error != nil {
+ description = t.S().Subtle.Render(fmt.Sprintf("error: %s", info.Error.Error()))
+ }
+ return t.ItemErrorIcon, description
+ case lsp.StateDisabled:
+ return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("inactive")
+ default:
+ return t.ItemOfflineIcon, ""
+ }
+}
+
// RenderLSPBlock renders a complete LSP block with optional truncation indicator.
func RenderLSPBlock(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions, showTruncationIndicator bool) string {
t := styles.CurrentTheme()
@@ -55,7 +55,7 @@ func RenderMCPList(opts RenderOptions) []string {
// Determine icon and color based on state
icon := t.ItemOfflineIcon
- description := l.MCP.Command
+ description := ""
extraContent := ""
if state, exists := mcpStates[l.Name]; exists {
@@ -3,14 +3,13 @@ package list
import (
"regexp"
"slices"
- "sort"
- "strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sahilm/fuzzy"
)
@@ -28,7 +27,9 @@ type FilterableList[T FilterableItem] interface {
Cursor() *tea.Cursor
SetInputWidth(int)
SetInputPlaceholder(string)
+ SetResultsSize(int)
Filter(q string) tea.Cmd
+ fuzzy.Source
}
type HasMatchIndexes interface {
@@ -47,10 +48,11 @@ type filterableList[T FilterableItem] struct {
*filterableOptions
width, height int
// stores all available items
- items []T
- input textinput.Model
- inputWidth int
- query string
+ items []T
+ resultsSize int
+ input textinput.Model
+ inputWidth int
+ query string
}
type filterableListOption func(*filterableOptions)
@@ -115,7 +117,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
return f
}
-func (f *filterableList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (f *filterableList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
@@ -246,22 +248,18 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
return f.list.SetItems(f.items)
}
- words := make([]string, len(f.items))
- for i, item := range f.items {
- words[i] = strings.ToLower(item.FilterValue())
- }
-
- matches := fuzzy.Find(query, words)
-
- sort.SliceStable(matches, func(i, j int) bool {
- return matches[i].Score > matches[j].Score
- })
+ matches := fuzzy.FindFrom(query, f)
var matchedItems []T
- for _, match := range matches {
+ resultSize := len(matches)
+ if f.resultsSize > 0 && resultSize > f.resultsSize {
+ resultSize = f.resultsSize
+ }
+ for i := range resultSize {
+ match := matches[i]
item := f.items[match.Index]
- if i, ok := any(item).(HasMatchIndexes); ok {
- i.MatchIndexes(match.MatchedIndexes)
+ if it, ok := any(item).(HasMatchIndexes); ok {
+ it.MatchIndexes(match.MatchedIndexes)
}
matchedItems = append(matchedItems, item)
}
@@ -307,3 +305,15 @@ func (f *filterableList[T]) SetInputWidth(w int) {
func (f *filterableList[T]) SetInputPlaceholder(ph string) {
f.placeholder = ph
}
+
+func (f *filterableList[T]) SetResultsSize(size int) {
+ f.resultsSize = size
+}
+
+func (f *filterableList[T]) String(i int) string {
+ return f.items[i].FilterValue()
+}
+
+func (f *filterableList[T]) Len() int {
+ return len(f.items)
+}
@@ -11,6 +11,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sahilm/fuzzy"
)
@@ -65,7 +66,7 @@ func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filter
return f
}
-func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (f *filterableGroupList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
@@ -58,7 +58,7 @@ func (g *groupedList[T]) Init() tea.Cmd {
return g.render()
}
-func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (l *groupedList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
u, cmd := l.list.Update(msg)
l.list = u.(*list[Item])
return l, cmd
@@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/google/uuid"
@@ -97,7 +98,7 @@ func (c *completionItemCmp[T]) Init() tea.Cmd {
}
// Update implements CommandItem.
-func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (c *completionItemCmp[T]) Update(tea.Msg) (util.Model, tea.Cmd) {
return c, nil
}
@@ -348,7 +349,7 @@ func (m *itemSectionModel) Init() tea.Cmd {
return nil
}
-func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (m *itemSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
return m, nil
}
@@ -15,6 +15,7 @@ import (
"github.com/charmbracelet/lipgloss/v2"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
+ "github.com/charmbracelet/x/exp/ordered"
"github.com/rivo/uniseg"
)
@@ -216,7 +217,7 @@ func (l *list[T]) Init() tea.Cmd {
}
// Update implements List.
-func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseWheelMsg:
if l.enableMouse {
@@ -276,7 +277,7 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return l, nil
}
-func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
+func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg.Button {
case tea.MouseWheelDown:
@@ -1283,14 +1284,14 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
newItem, ok := l.renderedItems.Get(item.ID())
if ok {
newLines := newItem.height - oldItem.height
- l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+ l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
}
}
} else if hasOldItem && l.offset > oldItem.start {
newItem, ok := l.renderedItems.Get(item.ID())
if ok {
newLines := newItem.height - oldItem.height
- l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+ l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
}
}
}
@@ -7,6 +7,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+ "github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/exp/golden"
"github.com/google/uuid"
@@ -602,7 +603,7 @@ func (s *simpleItem) Init() tea.Cmd {
return nil
}
-func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *simpleItem) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return s, nil
}
@@ -644,7 +645,7 @@ func (s *selectableItem) IsFocused() bool {
return s.focused
}
-func execCmd(m tea.Model, cmd tea.Cmd) {
+func execCmd(m util.Model, cmd tea.Cmd) {
for cmd != nil {
msg := cmd()
m, cmd = m.Update(msg)
@@ -163,7 +163,7 @@ func (p *chatPage) Init() tea.Cmd {
)
}
-func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyboardEnhancementsMsg:
@@ -3,6 +3,7 @@ package tui
import (
"context"
"fmt"
+ "math/rand"
"strings"
"time"
@@ -90,8 +91,6 @@ func (a appModel) Init() tea.Cmd {
cmd = a.status.Init()
cmds = append(cmds, cmd)
- cmds = append(cmds, tea.EnableMouseAllMotion)
-
return tea.Batch(cmds...)
}
@@ -105,9 +104,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyboardEnhancementsMsg:
for id, page := range a.pages {
m, pageCmd := page.Update(msg)
- if model, ok := m.(util.Model); ok {
- a.pages[id] = model
- }
+ a.pages[id] = m
if pageCmd != nil {
cmds = append(cmds, pageCmd)
@@ -231,9 +228,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Forward to view.
updated, itemCmd := item.Update(msg)
- if model, ok := updated.(util.Model); ok {
- a.pages[a.currentPage] = model
- }
+ a.pages[a.currentPage] = updated
return a, itemCmd
case pubsub.Event[permission.PermissionRequest]:
@@ -291,9 +286,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.isConfigured = config.HasInitialDataConfig()
updated, pageCmd := item.Update(msg)
- if model, ok := updated.(util.Model); ok {
- a.pages[a.currentPage] = model
- }
+ a.pages[a.currentPage] = updated
cmds = append(cmds, pageCmd)
return a, tea.Batch(cmds...)
@@ -313,9 +306,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
updated, pageCmd := item.Update(msg)
- if model, ok := updated.(util.Model); ok {
- a.pages[a.currentPage] = model
- }
+ a.pages[a.currentPage] = updated
cmds = append(cmds, pageCmd)
}
@@ -335,9 +326,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
updated, pageCmd := item.Update(msg)
- if model, ok := updated.(util.Model); ok {
- a.pages[a.currentPage] = model
- }
+ a.pages[a.currentPage] = updated
cmds = append(cmds, pageCmd)
}
@@ -352,9 +341,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
updated, cmd := item.Update(msg)
- if model, ok := updated.(util.Model); ok {
- a.pages[a.currentPage] = model
- }
+ a.pages[a.currentPage] = updated
if a.dialog.HasDialogs() {
u, dialogCmd := a.dialog.Update(msg)
@@ -390,9 +377,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
// Update the current view.
for p, page := range a.pages {
updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
- if model, ok := updated.(util.Model); ok {
- a.pages[p] = model
- }
+ a.pages[p] = updated
cmds = append(cmds, pageCmd)
}
@@ -495,9 +480,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
}
updated, cmd := item.Update(msg)
- if model, ok := updated.(util.Model); ok {
- a.pages[a.currentPage] = model
- }
+ a.pages[a.currentPage] = updated
return cmd
}
}
@@ -603,6 +586,13 @@ func (a *appModel) View() tea.View {
view.Layer = canvas
view.Cursor = cursor
+ view.MouseMode = tea.MouseModeCellMotion
+ view.AltScreen = true
+ if a.app != nil && a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
+ // HACK: use a random percentage to prevent ghostty from hiding it
+ // after a timeout.
+ view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
+ }
return view
}
@@ -12,8 +12,9 @@ type Cursor interface {
}
type Model interface {
- tea.Model
- tea.ViewModel
+ Init() tea.Cmd
+ Update(tea.Msg) (Model, tea.Cmd)
+ View() string
}
func CmdHandler(msg tea.Msg) tea.Cmd {
@@ -60,10 +61,3 @@ type (
}
ClearStatusMsg struct{}
)
-
-func Clamp(v, low, high int) int {
- if high < low {
- low, high = high, low
- }
- return min(high, max(low, v))
-}
@@ -3,23 +3,14 @@ package main
import (
"log/slog"
"net/http"
+ _ "net/http/pprof"
"os"
- _ "net/http/pprof" // profiling
-
- _ "github.com/joho/godotenv/autoload" // automatically load .env files
-
"github.com/charmbracelet/crush/internal/cmd"
- "github.com/charmbracelet/crush/internal/event"
- "github.com/charmbracelet/crush/internal/log"
+ _ "github.com/joho/godotenv/autoload"
)
func main() {
- defer log.RecoverPanic("main", func() {
- event.Flush()
- slog.Error("Application terminated due to unhandled panic")
- })
-
if os.Getenv("CRUSH_PROFILE") != "" {
go func() {
slog.Info("Serving pprof at localhost:6060")
@@ -19,6 +19,28 @@
"additionalProperties": false,
"type": "object"
},
+ "Completions": {
+ "properties": {
+ "max_depth": {
+ "type": "integer",
+ "description": "Maximum depth for the ls tool",
+ "default": 0,
+ "examples": [
+ 10
+ ]
+ },
+ "max_items": {
+ "type": "integer",
+ "description": "Maximum number of items to return for the ls tool",
+ "default": 1000,
+ "examples": [
+ 100
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "type": "object"
+ },
"Config": {
"properties": {
"$schema": {
@@ -53,10 +75,17 @@
"permissions": {
"$ref": "#/$defs/Permissions",
"description": "Permission settings for tool usage"
+ },
+ "tools": {
+ "$ref": "#/$defs/Tools",
+ "description": "Tool configurations"
}
},
"additionalProperties": false,
- "type": "object"
+ "type": "object",
+ "required": [
+ "tools"
+ ]
},
"LSPConfig": {
"properties": {
@@ -484,10 +513,51 @@
"split"
],
"description": "Diff mode for the TUI interface"
+ },
+ "completions": {
+ "$ref": "#/$defs/Completions",
+ "description": "Completions UI options"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "completions"
+ ]
+ },
+ "ToolLs": {
+ "properties": {
+ "max_depth": {
+ "type": "integer",
+ "description": "Maximum depth for the ls tool",
+ "default": 0,
+ "examples": [
+ 10
+ ]
+ },
+ "max_items": {
+ "type": "integer",
+ "description": "Maximum number of items to return for the ls tool",
+ "default": 1000,
+ "examples": [
+ 100
+ ]
}
},
"additionalProperties": false,
"type": "object"
+ },
+ "Tools": {
+ "properties": {
+ "ls": {
+ "$ref": "#/$defs/ToolLs"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "ls"
+ ]
}
}
}