Detailed changes
@@ -1007,6 +1007,70 @@
"created_at": "2026-01-01T21:00:07Z",
"repoId": 987670088,
"pullRequestNo": 1748
+ },
+ {
+ "name": "mohaanymo",
+ "id": 244024658,
+ "comment_id": 3725028621,
+ "created_at": "2026-01-08T18:01:11Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1799
+ },
+ {
+ "name": "zyriab",
+ "id": 2111910,
+ "comment_id": 3725966281,
+ "created_at": "2026-01-08T21:44:05Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1801
+ },
+ {
+ "name": "aleksclark",
+ "id": 607132,
+ "comment_id": 3729687747,
+ "created_at": "2026-01-09T16:28:21Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1811
+ },
+ {
+ "name": "jeis4wpi",
+ "id": 42679190,
+ "comment_id": 3735501265,
+ "created_at": "2026-01-11T19:19:03Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1827
+ },
+ {
+ "name": "uppet",
+ "id": 110209,
+ "comment_id": 3738688581,
+ "created_at": "2026-01-12T13:58:05Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1830
+ },
+ {
+ "name": "andreasdotorg",
+ "id": 153248,
+ "comment_id": 3740767910,
+ "created_at": "2026-01-12T22:16:05Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1841
+ },
+ {
+ "name": "kuxoapp",
+ "id": 254052994,
+ "comment_id": 3747622477,
+ "created_at": "2026-01-14T04:18:44Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1864
+ },
+ {
+ "name": "mhpenta",
+ "id": 183146177,
+ "comment_id": 3749703014,
+ "created_at": "2026-01-14T14:02:04Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1870
}
]
}
@@ -1,11 +1,27 @@
name: build
on: [push, pull_request]
+permissions:
+ contents: read
+
+concurrency:
+ group: build-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
jobs:
build:
- uses: charmbracelet/meta/.github/workflows/build.yml@main
- with:
- go-version: ""
- go-version-file: ./go.mod
- secrets:
- gh_pat: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
+ with:
+ go-version-file: go.mod
+ - run: go mod tidy
+ - run: git diff --exit-code
+ - run: go build -race ./...
+ - run: go test -race -failfast ./...
@@ -0,0 +1,92 @@
+name: "security"
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+ schedule:
+ - cron: "0 2 * * *"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: security-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ codeql:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ language: ["go", "actions"]
+ permissions:
+ actions: read
+ contents: read
+ pull-requests: read
+ security-events: write
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ persist-credentials: false
+ - uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
+ with:
+ languages: ${{ matrix.language }}
+ - uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
+ - uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
+
+ grype:
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ actions: read
+ contents: read
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ persist-credentials: false
+ - uses: anchore/scan-action@40a61b52209e9d50e87917c5b901783d546b12d0 # v7.2.1
+ id: scan
+ with:
+ path: "."
+ fail-build: true
+ severity-cutoff: critical
+ - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
+ with:
+ sarif_file: ${{ steps.scan.outputs.sarif }}
+
+ govulncheck:
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ contents: read
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
+ with:
+ go-version: 1.26.0-rc.1 # change to "stable" once Go 1.26 is released
+ - name: Install govulncheck
+ run: go install golang.org/x/vuln/cmd/govulncheck@latest
+ - name: Run govulncheck
+ run: |
+ govulncheck -C . -format sarif ./... > results.sarif
+ - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
+ with:
+ sarif_file: results.sarif
+
+ dependency-review:
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request'
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
+ with:
+ fail-on-severity: critical
+ allow-licenses: BSD-2-Clause, BSD-3-Clause, MIT, Apache-2.0, MPL-2.0, ISC, LicenseRef-scancode-google-patent-license-golang
@@ -1,7 +1,7 @@
# Crush
<p align="center">
- <a href="https://stuff.charm.sh/crush/charm-crush.png"><img width="450" alt="Charm Crush Logo" src="https://github.com/user-attachments/assets/adc1a6f4-b284-4603-836c-59038caa2e8b" /></a><br />
+ <a href="https://stuff.charm.sh/crush/charm-crush.png"><img width="450" alt="Charm Crush Logo" src="https://github.com/user-attachments/assets/cf8ca3ce-8b02-43f0-9d0f-5a331488da4b" /></a><br />
<a href="https://github.com/charmbracelet/crush/releases"><img src="https://img.shields.io/github/release/charmbracelet/crush" alt="Latest Release"></a>
<a href="https://github.com/charmbracelet/crush/actions"><img src="https://github.com/charmbracelet/crush/actions/workflows/build.yml/badge.svg" alt="Build Status"></a>
</p>
@@ -18,7 +18,8 @@
- **Session-Based:** maintain multiple work sessions and contexts per project
- **LSP-Enhanced:** Crush uses LSPs for additional context, just like you do
- **Extensible:** add capabilities via MCPs (`http`, `stdio`, and `sse`)
-- **Works Everywhere:** first-class support in every terminal on macOS, Linux, Windows (PowerShell and WSL), FreeBSD, OpenBSD, and NetBSD
+- **Works Everywhere:** first-class support in every terminal on macOS, Linux, Windows (PowerShell and WSL), Android, FreeBSD, OpenBSD, and NetBSD
+- **Industrial Grade:** built on the Charm ecosystem, powering 25k+ applications, from leading open source projects to business-critical infrastructure
## Installation
@@ -36,6 +37,9 @@ yay -S crush-bin
# Nix
nix run github:numtide/nix-ai-tools#crush
+
+# FreeBSD
+pkg install crush
```
Windows users:
@@ -52,9 +56,9 @@ scoop install crush
<details>
<summary><strong>Nix (NUR)</strong></summary>
-Crush is available via [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`.
+Crush is available via the official Charm [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`, which is the most up-to-date way to get Crush in Nix.
-You can also try out Crush via `nix-shell`:
+You can also try out Crush via the NUR with `nix-shell`:
```bash
# Add the NUR channel.
@@ -5,6 +5,8 @@ version: "3"
vars:
VERSION:
sh: git describe --long 2>/dev/null || echo ""
+ RACE:
+ sh: test -f race.log && echo "1" || echo ""
env:
CGO_ENABLED: 0
@@ -37,19 +39,20 @@ tasks:
vars:
LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
cmds:
- - go build {{.LDFLAGS}} .
+ - "go build {{if .RACE}}-race{{end}} {{.LDFLAGS}} ."
generates:
- crush
run:
desc: Run build
cmds:
- - go run . {{.CLI_ARGS}}
+ - task: build
+ - "./crush {{.CLI_ARGS}} {{if .RACE}}2>race.log{{end}}"
test:
desc: Run tests
cmds:
- - go test ./... {{.CLI_ARGS}}
+ - go test -race -failfast ./... {{.CLI_ARGS}}
test:record:
desc: Run tests and record all VCR cassettes again
@@ -3,9 +3,9 @@ module github.com/charmbracelet/crush
go 1.25.5
require (
- charm.land/bubbles/v2 v2.0.0-rc.1
+ charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66
charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e
- charm.land/fantasy v0.6.0
+ charm.land/fantasy v0.6.1
charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971
charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da
@@ -13,22 +13,24 @@ require (
github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/MakeNowJust/heredoc v1.0.0
github.com/PuerkitoBio/goquery v1.11.0
- github.com/alecthomas/chroma/v2 v2.21.1
+ github.com/alecthomas/chroma/v2 v2.22.0
github.com/atotto/clipboard v0.1.4
+ github.com/aymanbagabas/go-nativeclipboard v0.1.2
github.com/aymanbagabas/go-udiff v0.3.1
- github.com/bmatcuk/doublestar/v4 v4.9.1
+ github.com/bmatcuk/doublestar/v4 v4.9.2
github.com/charlievieth/fastwalk v1.0.14
- github.com/charmbracelet/catwalk v0.12.2
+ github.com/charmbracelet/catwalk v0.14.1
github.com/charmbracelet/colorprofile v0.4.1
github.com/charmbracelet/fang v0.4.4
github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
github.com/charmbracelet/x/ansi v0.11.3
+ github.com/charmbracelet/x/editor v0.2.0
github.com/charmbracelet/x/etag v0.2.0
- github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
+ github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
github.com/charmbracelet/x/exp/ordered v0.1.0
github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
- github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
+ github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
github.com/charmbracelet/x/term v0.2.2
github.com/denisbrodbeck/machineid v1.0.1
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
@@ -43,7 +45,7 @@ require (
github.com/nxadm/tail v1.4.11
github.com/openai/openai-go/v2 v2.7.1
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
- github.com/posthog/posthog-go v1.8.2
+ github.com/posthog/posthog-go v1.9.0
github.com/pressly/goose/v3 v3.26.0
github.com/qjebbs/go-jsons v1.0.0-alpha.4
github.com/rivo/uniseg v0.4.7
@@ -56,12 +58,13 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/zeebo/xxh3 v1.0.2
- golang.org/x/mod v0.31.0
- golang.org/x/net v0.48.0
+ golang.org/x/mod v0.32.0
+ golang.org/x/net v0.49.0
golang.org/x/sync v0.19.0
- golang.org/x/text v0.32.0
+ golang.org/x/text v0.33.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
+ modernc.org/sqlite v1.43.0
mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
)
@@ -75,20 +78,20 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/RealAlexandreAI/json-repair v0.0.14 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
- github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect
+ github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
- github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect
- github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
+ github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
- github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
- github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
@@ -98,13 +101,14 @@ require (
github.com/charmbracelet/x/json v0.2.0 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
- github.com/clipperhouse/displaywidth v0.6.1 // indirect
+ github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/disintegration/gift v1.1.2 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
@@ -112,6 +116,7 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
@@ -124,7 +129,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kaptinlin/go-i18n v0.2.2 // indirect
github.com/kaptinlin/jsonpointer v0.4.8 // indirect
- github.com/kaptinlin/jsonschema v0.6.5 // indirect
+ github.com/kaptinlin/jsonschema v0.6.6 // indirect
github.com/kaptinlin/messageformat-go v0.4.7 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
@@ -140,9 +145,11 @@ require (
github.com/muesli/mango-cobra v1.2.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
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
@@ -165,18 +172,21 @@ require (
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
- golang.org/x/crypto v0.46.0 // indirect
+ golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
- golang.org/x/image v0.27.0 // indirect
+ golang.org/x/image v0.34.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
- golang.org/x/sys v0.39.0 // indirect
- golang.org/x/term v0.38.0 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/term v0.39.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.239.0 // indirect
- google.golang.org/genai v1.40.0 // indirect
+ google.golang.org/genai v1.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+ modernc.org/libc v1.66.10 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
)
@@ -1,9 +1,9 @@
-charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM=
-charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
+charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv9xq6ZS+x0mtacfxpxjIK1KUIeTqBOs=
+charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ=
charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8=
-charm.land/fantasy v0.6.0 h1:0PZfZ/w6c70UdlumGGFW6s9zTV6f4xAV/bXo6vGuZsc=
-charm.land/fantasy v0.6.0/go.mod h1:hUyklhBbCtnVeMAWGXHbMD4A+5B8dHbYHGZDfOYpzzw=
+charm.land/fantasy v0.6.1 h1:v3pavSHpZ5xTw98TpNYoj6DRq4ksCBWwJiZeiG/mVIc=
+charm.land/fantasy v0.6.1/go.mod h1:Ifj41bNnIXJ1aF6sLKcS9y3MzWbDnObmcHrCaaHfpZ0=
charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 h1:9q4+yyU7105T3OrOx0csMyKnw89yMSijJ+rVld/Z2ek=
charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8=
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiIDbFtWRyDmkKNk1sjojfaom4Zoe0cyH/8c=
@@ -39,8 +39,8 @@ github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x
github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
-github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
-github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
+github.com/alecthomas/chroma/v2 v2.22.0 h1:PqEhf+ezz5F5owoDeOUKFzW+W3ZJDShNCaHg4sZuItI=
+github.com/alecthomas/chroma/v2 v2.22.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
@@ -48,36 +48,38 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
-github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
+github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
+github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
-github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
-github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
+github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
+github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
+github.com/aymanbagabas/go-nativeclipboard v0.1.2 h1:Z2iVRWQ4IynMLWM6a+lWH2Nk5gPyEtPRMuBIyZ2dECM=
+github.com/aymanbagabas/go-nativeclipboard v0.1.2/go.mod h1:BVJhN7hs5DieCzUB2Atf4Yk9Y9kFe62E95+gOjpJq6Q=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
@@ -86,16 +88,16 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
-github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
-github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI=
+github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
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/catwalk v0.12.2 h1:zq9b+7kiumof/Dzvqi/oHnwMBgSN/M2Yt82vlIAiKMU=
-github.com/charmbracelet/catwalk v0.12.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
+github.com/charmbracelet/catwalk v0.14.1 h1:n16H880MHW8PPgQeh0dorP77AJMxw5JcOUPuC3FFhaQ=
+github.com/charmbracelet/catwalk v0.14.1/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
@@ -104,10 +106,12 @@ github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 h1:j3PW2
github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560/go.mod h1:VWATWLRwYP06VYCEur7FsNR2B1xAo7Y+xl1PTbd1ePc=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
+github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk=
+github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY=
github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04=
github.com/charmbracelet/x/etag v0.2.0/go.mod h1:C1B7/bsgvzzxpfu0Rabbd+rTHJa5TmC/qgTseCf6DF0=
-github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
-github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f h1:OKFNbG2sSmgpQW9EC3gYNG+QrcQ4+wWYjzfmJvWkkDo=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
@@ -116,16 +120,16 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/
github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
-github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
-github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
+github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
+github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
-github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
-github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
+github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
@@ -148,6 +152,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 h1:7LxHj6bTGLfcjjDMZyTH8ZDB8nQrcwoFNr1s4yiWtac=
+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
@@ -171,6 +177,8 @@ github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
@@ -182,6 +190,8 @@ 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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -210,8 +220,8 @@ github.com/kaptinlin/go-i18n v0.2.2 h1:kebVCZme/BrCTqonh/J+VYCl1+Of5C18bvyn3DRPl
github.com/kaptinlin/go-i18n v0.2.2/go.mod h1:MiwkeHryBopAhC/M3zEwIM/2IN8TvTqJQswPw6kceqM=
github.com/kaptinlin/jsonpointer v0.4.8 h1:HocHcXrOBfP/nUJw0YYjed/TlQvuCAY6uRs3Qok7F6g=
github.com/kaptinlin/jsonpointer v0.4.8/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA=
-github.com/kaptinlin/jsonschema v0.6.5 h1:hC7upwWlvamWqeTVQ3ab20F4w0XKNKR1drY9apoqGOU=
-github.com/kaptinlin/jsonschema v0.6.5/go.mod h1:EbhSbdxZ4QjzIORdMWOrRXJeCHrLTJqXDA8JzNaeFc8=
+github.com/kaptinlin/jsonschema v0.6.6 h1:UmIF1amA5ijCGSk4tl4ViNlgYL4jzHHvY+Nd5cnkfDI=
+github.com/kaptinlin/jsonschema v0.6.6/go.mod h1:EbhSbdxZ4QjzIORdMWOrRXJeCHrLTJqXDA8JzNaeFc8=
github.com/kaptinlin/messageformat-go v0.4.7 h1:HQ/OvFUSU7+fAHWkZnP2ug9y+A/ZyTE8j33jfWr8O3Q=
github.com/kaptinlin/messageformat-go v0.4.7/go.mod h1:DusKpv8CIybczGvwIVn3j13hbR3psr5mOwhFudkiq1c=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -280,8 +290,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
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.8.2 h1:v/ajsM8lq+2Z3OlQbTVWqiHI+hyh9Cd4uiQt1wFlehE=
-github.com/posthog/posthog-go v1.8.2/go.mod h1:ueZiJCmHezyDHI/swIR1RmOfktLehnahJnFxEvQ9mnQ=
+github.com/posthog/posthog-go v1.9.0 h1:7tRfnaHqPNrBNTnSnFLQwJ5aVz6LOBngiwl15lD8bHU=
+github.com/posthog/posthog-go v1.9.0/go.mod h1:0i1H2BlsK9mHvHGc9Kp6oenUlHUqPl45hWzRtR/2PVI=
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=
@@ -381,19 +391,19 @@ 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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
-golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
-golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
-golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
+golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
+golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
-golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -405,8 +415,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
-golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -434,8 +444,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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
-golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.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=
@@ -447,8 +457,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.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
-golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
+golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
+golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
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=
@@ -458,8 +468,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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
-golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -468,15 +478,15 @@ 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.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
-golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
+golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
+golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
-google.golang.org/genai v1.40.0 h1:kYxyQSH+vsib8dvsgyLJzsVEIv5k3ZmHJyVqdvGncmc=
-google.golang.org/genai v1.40.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
+google.golang.org/genai v1.41.0 h1:ayXl75LjTmqTu0y94yr96d17gIb4zF8gWVzX2TgioEY=
+google.golang.org/genai v1.41.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
@@ -499,14 +509,32 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
-modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
+modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
+modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
+modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
+modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
+modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
+modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
-modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
-modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA=
+modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 h1:mO2lyKtGwu4mGQ+Qqjx0+fd5UU5BXhX/rslFmxd5aco=
mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5/go.mod h1:Of9PCedbLDYT8b3EyiYG64rNnx5nOp27OLCVdDrjJyo=
mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 h1:e7Z/Lgw/zMijvQBVrfh/vUDZ+9FpuSLrJDVGBuoJtuo=
@@ -38,9 +38,17 @@ import (
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/stringext"
+ "github.com/charmbracelet/x/exp/charmtone"
)
-const defaultSessionName = "Untitled Session"
+const (
+ defaultSessionName = "Untitled Session"
+
+ // Constants for auto-summarization thresholds
+ largeContextWindowThreshold = 200_000
+ largeContextWindowBuffer = 20_000
+ smallContextWindowRatio = 0.2
+)
//go:embed templates/title.md
var titlePrompt []byte
@@ -68,6 +76,7 @@ type SessionAgent interface {
Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)
SetModels(large Model, small Model)
SetTools(tools []fantasy.AgentTool)
+ SetSystemPrompt(systemPrompt string)
Cancel(sessionID string)
CancelAll()
IsSessionBusy(sessionID string) bool
@@ -86,12 +95,13 @@ type Model struct {
}
type sessionAgent struct {
- largeModel Model
- smallModel Model
- systemPromptPrefix string
- systemPrompt string
+ largeModel *csync.Value[Model]
+ smallModel *csync.Value[Model]
+ systemPromptPrefix *csync.Value[string]
+ systemPrompt *csync.Value[string]
+ tools *csync.Slice[fantasy.AgentTool]
+
isSubAgent bool
- tools []fantasy.AgentTool
sessions session.Service
messages message.Service
disableAutoSummarize bool
@@ -118,15 +128,15 @@ func NewSessionAgent(
opts SessionAgentOptions,
) SessionAgent {
return &sessionAgent{
- largeModel: opts.LargeModel,
- smallModel: opts.SmallModel,
- systemPromptPrefix: opts.SystemPromptPrefix,
- systemPrompt: opts.SystemPrompt,
+ largeModel: csync.NewValue(opts.LargeModel),
+ smallModel: csync.NewValue(opts.SmallModel),
+ systemPromptPrefix: csync.NewValue(opts.SystemPromptPrefix),
+ systemPrompt: csync.NewValue(opts.SystemPrompt),
isSubAgent: opts.IsSubAgent,
sessions: opts.Sessions,
messages: opts.Messages,
disableAutoSummarize: opts.DisableAutoSummarize,
- tools: opts.Tools,
+ tools: csync.NewSliceFrom(opts.Tools),
isYolo: opts.IsYolo,
messageQueue: csync.NewMap[string, []SessionAgentCall](),
activeRequests: csync.NewMap[string, context.CancelFunc](),
@@ -134,7 +144,7 @@ func NewSessionAgent(
}
func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
- if call.Prompt == "" {
+ if call.Prompt == "" && !message.ContainsTextAttachment(call.Attachments) {
return nil, ErrEmptyPrompt
}
if call.SessionID == "" {
@@ -152,15 +162,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
return nil, nil
}
- if len(a.tools) > 0 {
+ // Copy mutable fields under lock to avoid races with SetTools/SetModels.
+ agentTools := a.tools.Copy()
+ largeModel := a.largeModel.Get()
+ systemPrompt := a.systemPrompt.Get()
+ promptPrefix := a.systemPromptPrefix.Get()
+
+ if len(agentTools) > 0 {
// Add Anthropic caching to the last tool.
- a.tools[len(a.tools)-1].SetProviderOptions(a.getCacheControlOptions())
+ agentTools[len(agentTools)-1].SetProviderOptions(a.getCacheControlOptions())
}
agent := fantasy.NewAgent(
- a.largeModel.Model,
- fantasy.WithSystemPrompt(a.systemPrompt),
- fantasy.WithTools(a.tools...),
+ largeModel.Model,
+ fantasy.WithSystemPrompt(systemPrompt),
+ fantasy.WithTools(agentTools...),
)
sessionLock := sync.Mutex{}
@@ -182,6 +198,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
a.generateTitle(titleCtx, call.SessionID, call.Prompt)
})
}
+ defer wg.Wait()
// Add the user message to the session.
_, err = a.createUserMessage(ctx, call)
@@ -232,7 +249,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
}
- prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages)
+ prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages, largeModel)
lastSystemRoleInx := 0
systemMessageUpdated := false
@@ -250,7 +267,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
}
}
- if promptPrefix := a.promptPrefix(); promptPrefix != "" {
+ if promptPrefix != "" {
prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
}
@@ -258,15 +275,15 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
Role: message.Assistant,
Parts: []message.ContentPart{},
- Model: a.largeModel.ModelCfg.Model,
- Provider: a.largeModel.ModelCfg.Provider,
+ Model: largeModel.ModelCfg.Model,
+ Provider: largeModel.ModelCfg.Provider,
})
if err != nil {
return callContext, prepared, err
}
callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
- callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, a.largeModel.CatwalkCfg.SupportsImages)
- callContext = context.WithValue(callContext, tools.ModelNameContextKey, a.largeModel.CatwalkCfg.Name)
+ callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, largeModel.CatwalkCfg.SupportsImages)
+ callContext = context.WithValue(callContext, tools.ModelNameContextKey, largeModel.CatwalkCfg.Name)
currentAssistant = &assistantMsg
return callContext, prepared, err
},
@@ -360,7 +377,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
sessionLock.Unlock()
return getSessionErr
}
- a.updateSessionUsage(a.largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
+ a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
_, sessionErr := a.sessions.Save(genCtx, updatedSession)
sessionLock.Unlock()
if sessionErr != nil {
@@ -370,14 +387,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
},
StopWhen: []fantasy.StopCondition{
func(_ []fantasy.StepResult) bool {
- cw := int64(a.largeModel.CatwalkCfg.ContextWindow)
+ cw := int64(largeModel.CatwalkCfg.ContextWindow)
tokens := currentSession.CompletionTokens + currentSession.PromptTokens
remaining := cw - tokens
var threshold int64
- if cw > 200_000 {
- threshold = 20_000
+ if cw > largeContextWindowThreshold {
+ threshold = largeContextWindowBuffer
} else {
- threshold = int64(float64(cw) * 0.2)
+ threshold = int64(float64(cw) * smallContextWindowRatio)
}
if (remaining <= threshold) && !a.disableAutoSummarize {
shouldSummarize = true
@@ -444,7 +461,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
Content: content,
IsError: true,
}
- _, createErr = a.messages.Create(context.Background(), currentAssistant.SessionID, message.CreateMessageParams{
+ _, createErr = a.messages.Create(ctx, currentAssistant.SessionID, message.CreateMessageParams{
Role: message.Tool,
Parts: []message.ContentPart{
toolResult,
@@ -457,22 +474,23 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
var fantasyErr *fantasy.Error
var providerErr *fantasy.ProviderError
const defaultTitle = "Provider Error"
+ linkStyle := lipgloss.NewStyle().Foreground(charmtone.Guac).Underline(true)
if isCancelErr {
currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
} else if isPermissionErr {
currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
} else if errors.Is(err, hyper.ErrNoCredits) {
url := hyper.BaseURL()
- link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url)
+ link := linkStyle.Hyperlink(url, "id=hyper").Render(url)
currentAssistant.AddFinish(message.FinishReasonError, "No credits", "You're out of credits. Add more at "+link)
} else if errors.As(err, &providerErr) {
if providerErr.Message == "The requested model is not supported." {
url := "https://github.com/settings/copilot/features"
- link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url)
+ link := linkStyle.Hyperlink(url, "id=copilot").Render(url)
currentAssistant.AddFinish(
message.FinishReasonError,
"Copilot model not enabled",
- fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait 5 minutes before trying again. %s", a.largeModel.CatwalkCfg.Name, link),
+ fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait 5 minutes before trying again. %s", largeModel.CatwalkCfg.Name, link),
)
} else {
currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
@@ -490,7 +508,6 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
}
return nil, err
}
- wg.Wait()
if shouldSummarize {
a.activeRequests.Del(call.SessionID)
@@ -528,6 +545,10 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
return ErrSessionBusy
}
+ // Copy mutable fields under lock to avoid races with SetModels.
+ largeModel := a.largeModel.Get()
+ systemPromptPrefix := a.systemPromptPrefix.Get()
+
currentSession, err := a.sessions.Get(ctx, sessionID)
if err != nil {
return fmt.Errorf("failed to get session: %w", err)
@@ -548,28 +569,20 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
defer a.activeRequests.Del(sessionID)
defer cancel()
- agent := fantasy.NewAgent(a.largeModel.Model,
+ agent := fantasy.NewAgent(largeModel.Model,
fantasy.WithSystemPrompt(string(summaryPrompt)),
)
summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
Role: message.Assistant,
- Model: a.largeModel.Model.Model(),
- Provider: a.largeModel.Model.Provider(),
+ Model: largeModel.Model.Model(),
+ Provider: largeModel.Model.Provider(),
IsSummaryMessage: true,
})
if err != nil {
return err
}
- summaryPromptText := "Provide a detailed summary of our conversation above."
- if len(currentSession.Todos) > 0 {
- summaryPromptText += "\n\n## Current Todo List\n\n"
- for _, t := range currentSession.Todos {
- summaryPromptText += fmt.Sprintf("- [%s] %s\n", t.Status, t.Content)
- }
- summaryPromptText += "\nInclude these tasks and their statuses in your summary. "
- summaryPromptText += "Instruct the resuming assistant to use the `todos` tool to continue tracking progress on these tasks."
- }
+ summaryPromptText := buildSummaryPrompt(currentSession.Todos)
resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
Prompt: summaryPromptText,
@@ -577,8 +590,8 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
ProviderOptions: opts,
PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
prepared.Messages = options.Messages
- if a.systemPromptPrefix != "" {
- prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
+ if systemPromptPrefix != "" {
+ prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(systemPromptPrefix)}, prepared.Messages...)
}
return callContext, prepared, nil
},
@@ -629,7 +642,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
}
}
- a.updateSessionUsage(a.largeModel, ¤tSession, resp.TotalUsage, openrouterCost)
+ a.updateSessionUsage(largeModel, ¤tSession, resp.TotalUsage, openrouterCost)
// Just in case, get just the last usage info.
usage := resp.Response.Usage
@@ -716,15 +729,15 @@ func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.S
}
if session.SummaryMessageID != "" {
- summaryMsgInex := -1
+ summaryMsgIndex := -1
for i, msg := range msgs {
if msg.ID == session.SummaryMessageID {
- summaryMsgInex = i
+ summaryMsgIndex = i
break
}
}
- if summaryMsgInex != -1 {
- msgs = msgs[summaryMsgInex:]
+ if summaryMsgIndex != -1 {
+ msgs = msgs[summaryMsgIndex:]
msgs[0].Role = message.User
}
}
@@ -737,9 +750,13 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
return
}
+ smallModel := a.smallModel.Get()
+ largeModel := a.largeModel.Get()
+ systemPromptPrefix := a.systemPromptPrefix.Get()
+
var maxOutputTokens int64 = 40
- if a.smallModel.CatwalkCfg.CanReason {
- maxOutputTokens = a.smallModel.CatwalkCfg.DefaultMaxTokens
+ if smallModel.CatwalkCfg.CanReason {
+ maxOutputTokens = smallModel.CatwalkCfg.DefaultMaxTokens
}
newAgent := func(m fantasy.LanguageModel, p []byte, tok int64) fantasy.Agent {
@@ -753,9 +770,9 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", userPrompt),
PrepareStep: func(callCtx context.Context, opts fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
prepared.Messages = opts.Messages
- if a.systemPromptPrefix != "" {
+ if systemPromptPrefix != "" {
prepared.Messages = append([]fantasy.Message{
- fantasy.NewSystemMessage(a.systemPromptPrefix),
+ fantasy.NewSystemMessage(systemPromptPrefix),
}, prepared.Messages...)
}
return callCtx, prepared, nil
@@ -763,7 +780,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
}
// Use the small model to generate the title.
- model := &a.smallModel
+ model := smallModel
agent := newAgent(model.Model, titlePrompt, maxOutputTokens)
resp, err := agent.Stream(ctx, streamCall)
if err == nil {
@@ -772,7 +789,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
} else {
// It didn't work. Let's try with the big model.
slog.Error("error generating title with small model; trying big model", "err", err)
- model = &a.largeModel
+ model = largeModel
agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
resp, err = agent.Stream(ctx, streamCall)
if err == nil {
@@ -803,7 +820,6 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
// Clean up title.
var title string
title = strings.ReplaceAll(resp.Response.Content.Text(), "\n", " ")
- slog.Info("generated title", "title", title)
// Remove thinking tags if present.
title = thinkTagRegex.ReplaceAllString(title, "")
@@ -833,10 +849,6 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
modelConfig.CostPer1MOut/1e6*float64(resp.TotalUsage.OutputTokens)
- if a.isClaudeCode() {
- cost = 0
- }
-
// Use override cost if available (e.g., from OpenRouter).
if openrouterCost != nil {
cost = *openrouterCost
@@ -874,10 +886,6 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session,
modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
- if a.isClaudeCode() {
- cost = 0
- }
-
a.eventTokensUsed(session.ID, model, usage, cost)
if overrideCost != nil {
@@ -891,14 +899,17 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session,
}
func (a *sessionAgent) Cancel(sessionID string) {
- // Cancel regular requests.
- if cancel, ok := a.activeRequests.Take(sessionID); ok && cancel != nil {
+ // Cancel regular requests. Don't use Take() here - we need the entry to
+ // remain in activeRequests so IsBusy() returns true until the goroutine
+ // fully completes (including error handling that may access the DB).
+ // The defer in processRequest will clean up the entry.
+ if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil {
slog.Info("Request cancellation initiated", "session_id", sessionID)
cancel()
}
// Also check for summarize requests.
- if cancel, ok := a.activeRequests.Take(sessionID + "-summarize"); ok && cancel != nil {
+ if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil {
slog.Info("Summarize cancellation initiated", "session_id", sessionID)
cancel()
}
@@ -972,30 +983,20 @@ func (a *sessionAgent) QueuedPromptsList(sessionID string) []string {
}
func (a *sessionAgent) SetModels(large Model, small Model) {
- a.largeModel = large
- a.smallModel = small
+ a.largeModel.Set(large)
+ a.smallModel.Set(small)
}
func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
- a.tools = tools
+ a.tools.SetSlice(tools)
}
-func (a *sessionAgent) Model() Model {
- return a.largeModel
-}
-
-func (a *sessionAgent) promptPrefix() string {
- if a.isClaudeCode() {
- return "You are Claude Code, Anthropic's official CLI for Claude."
- }
- return a.systemPromptPrefix
+func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
+ a.systemPrompt.Set(systemPrompt)
}
-// XXX: this should be generalized to cover other subscription plans, like Copilot.
-func (a *sessionAgent) isClaudeCode() bool {
- cfg := config.Get()
- pc, ok := cfg.Providers.Get(a.largeModel.ModelCfg.Provider)
- return ok && pc.ID == string(catwalk.InferenceProviderAnthropic) && pc.OAuthToken != nil
+func (a *sessionAgent) Model() Model {
+ return a.largeModel.Get()
}
// convertToToolResult converts a fantasy tool result to a message tool result.
@@ -1052,9 +1053,9 @@ func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) mes
//
// BEFORE: [tool result: image data]
// AFTER: [tool result: "Image loaded - see attached"], [user: image attachment]
-func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message) []fantasy.Message {
- providerSupportsMedia := a.largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
- a.largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
+func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message, largeModel Model) []fantasy.Message {
+ providerSupportsMedia := largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
+ largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
if providerSupportsMedia {
return messages
@@ -1119,3 +1120,18 @@ func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Mes
return convertedMessages
}
+
+// buildSummaryPrompt constructs the prompt text for session summarization.
+func buildSummaryPrompt(todos []session.Todo) string {
+ var sb strings.Builder
+ sb.WriteString("Provide a detailed summary of our conversation above.")
+ if len(todos) > 0 {
+ sb.WriteString("\n\n## Current Todo List\n\n")
+ for _, t := range todos {
+ fmt.Fprintf(&sb, "- [%s] %s\n", t.Status, t.Content)
+ }
+ sb.WriteString("\nInclude these tasks and their statuses in your summary. ")
+ sb.WriteString("Instruct the resuming assistant to use the `todos` tool to continue tracking progress on these tasks.")
+ }
+ return sb.String()
+}
@@ -1,6 +1,7 @@
package agent
import (
+ "fmt"
"os"
"path/filepath"
"runtime"
@@ -11,6 +12,7 @@ import (
"charm.land/x/vcr"
"github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/session"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -454,7 +456,7 @@ func TestCoderAgent(t *testing.T) {
})
t.Run("sourcegraph tool", func(t *testing.T) {
if runtime.GOOS == "darwin" {
- t.Skip("skipping flacky test on macos for now")
+ t.Skip("skipping flakey test on macos for now")
}
agent, env := setupAgent(t, pair)
@@ -619,3 +621,37 @@ func TestCoderAgent(t *testing.T) {
})
}
}
+
+func makeTestTodos(n int) []session.Todo {
+ todos := make([]session.Todo, n)
+ for i := range n {
+ todos[i] = session.Todo{
+ Status: session.TodoStatusPending,
+ Content: fmt.Sprintf("Task %d: Implement feature with some description that makes it realistic", i),
+ }
+ }
+ return todos
+}
+
+func BenchmarkBuildSummaryPrompt(b *testing.B) {
+ cases := []struct {
+ name string
+ numTodos int
+ }{
+ {"0todos", 0},
+ {"5todos", 5},
+ {"10todos", 10},
+ {"50todos", 50},
+ }
+
+ for _, tc := range cases {
+ todos := makeTestTodos(tc.numTodos)
+
+ b.Run(tc.name, func(b *testing.B) {
+ b.ReportAllocs()
+ for range b.N {
+ _ = buildSummaryPrompt(todos)
+ }
+ })
+ }
+}
@@ -79,7 +79,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
description = "Search the web and analyze results"
}
- p := c.permissions.Request(
+ p, err := c.permissions.Request(ctx,
permission.CreatePermissionRequest{
SessionID: validationResult.SessionID,
Path: c.cfg.WorkingDir(),
@@ -90,7 +90,9 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
Params: tools.AgenticFetchPermissionsParams(params),
},
)
-
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !p {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -183,6 +183,10 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
// would be included in prompt and break VCR cassette matching.
cfg.Options.SkillsPaths = []string{}
+ // Clear LSP config to ensure test reproducibility - user's LSP config
+ // would be included in prompt and break VCR cassette matching.
+ cfg.LSP = nil
+
systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
if err != nil {
return nil, err
@@ -20,6 +20,7 @@ import (
"github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/agent/prompt"
"github.com/charmbracelet/crush/internal/agent/tools"
+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/history"
@@ -317,17 +318,12 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
return nil, err
}
- systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
- if err != nil {
- return nil, err
- }
-
largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
result := NewSessionAgent(SessionAgentOptions{
large,
small,
largeProviderCfg.SystemPromptPrefix,
- systemPrompt,
+ "",
isSubAgent,
c.cfg.Options.DisableAutoSummarize,
c.permissions.SkipRequests(),
@@ -335,6 +331,16 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
c.messages,
nil,
})
+
+ c.readyWg.Go(func() error {
+ systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
+ if err != nil {
+ return err
+ }
+ result.SetSystemPrompt(systemPrompt)
+ return nil
+ })
+
c.readyWg.Go(func() error {
tools, err := c.buildTools(ctx, agent)
if err != nil {
@@ -415,6 +421,11 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
}
}
+ // Wait for MCP initialization to complete before reading MCP tools.
+ if err := mcp.WaitForInit(ctx); err != nil {
+ return nil, fmt.Errorf("failed to wait for MCP initialization: %w", err)
+ }
+
for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
if agent.AllowedMCP == nil {
// No MCP restrictions
@@ -493,7 +504,7 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo
}
if smallCatwalkModel == nil {
- return Model{}, Model{}, errors.New("snall model not found in provider config")
+ return Model{}, Model{}, errors.New("small model not found in provider config")
}
largeModelID := largeModelCfg.Model
@@ -527,13 +538,13 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo
}, nil
}
-func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, isOauth bool) (fantasy.Provider, error) {
+func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
var opts []anthropic.Option
- if isOauth {
+ if strings.HasPrefix(apiKey, "Bearer ") {
// NOTE: Prevent the SDK from picking up the API key from env.
os.Setenv("ANTHROPIC_API_KEY", "")
- headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
+ headers["Authorization"] = apiKey
} else if apiKey != "" {
// X-Api-Key header
opts = append(opts, anthropic.WithAPIKey(apiKey))
@@ -740,7 +751,7 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
case openai.Name:
return c.buildOpenaiProvider(baseURL, apiKey, headers)
case anthropic.Name:
- return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.OAuthToken != nil)
+ return c.buildAnthropicProvider(baseURL, apiKey, headers)
case openrouter.Name:
return c.buildOpenrouterProvider(baseURL, apiKey, headers)
case azure.Name:
@@ -7,23 +7,23 @@ import (
"github.com/charmbracelet/crush/internal/event"
)
-func (a sessionAgent) eventPromptSent(sessionID string) {
+func (a *sessionAgent) eventPromptSent(sessionID string) {
event.PromptSent(
- a.eventCommon(sessionID, a.largeModel)...,
+ a.eventCommon(sessionID, a.largeModel.Get())...,
)
}
-func (a sessionAgent) eventPromptResponded(sessionID string, duration time.Duration) {
+func (a *sessionAgent) eventPromptResponded(sessionID string, duration time.Duration) {
event.PromptResponded(
append(
- a.eventCommon(sessionID, a.largeModel),
+ a.eventCommon(sessionID, a.largeModel.Get()),
"prompt duration pretty", duration.String(),
"prompt duration in seconds", int64(duration.Seconds()),
)...,
)
}
-func (a sessionAgent) eventTokensUsed(sessionID string, model Model, usage fantasy.Usage, cost float64) {
+func (a *sessionAgent) eventTokensUsed(sessionID string, model Model, usage fantasy.Usage, cost float64) {
event.TokensUsed(
append(
a.eventCommon(sessionID, model),
@@ -37,7 +37,7 @@ func (a sessionAgent) eventTokensUsed(sessionID string, model Model, usage fanta
)
}
-func (a sessionAgent) eventCommon(sessionID string, model Model) []any {
+func (a *sessionAgent) eventCommon(sessionID string, model Model) []any {
m := model.ModelCfg
return []any{
@@ -326,5 +326,5 @@ func retryAfter(resp *http.Response) string {
d := time.Duration(after) * time.Second
return "Try again in " + d.String()
}
- return "Try again in later"
+ return "Try again later"
}
@@ -215,7 +215,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution
return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
}
if !isSafeReadOnly {
- p := permissions.Request(
+ p, err := permissions.Request(ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: execWorkingDir,
@@ -226,6 +226,9 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution
Params: BashPermissionsParams(params),
},
)
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !p {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -16,7 +16,7 @@ import (
)
type DiagnosticsParams struct {
- FilePath string `json:"file_path,omitempty" description:"The path to the file to get diagnostics for (leave w empty for project diagnostics)"`
+ FilePath string `json:"file_path,omitempty" description:"The path to the file to get diagnostics for (leave empty for project diagnostics)"`
}
const DiagnosticsToolName = "lsp_diagnostics"
@@ -70,7 +70,7 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for downloading files")
}
- p := permissions.Request(
+ p, err := permissions.Request(ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: filePath,
@@ -80,7 +80,9 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
Params: DownloadPermissionsParams(params),
},
)
-
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !p {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -44,6 +44,11 @@ type EditResponseMetadata struct {
const EditToolName = "edit"
+var (
+ oldStringNotFoundErr = fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks.")
+ oldStringMultipleMatchesErr = fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true")
+)
+
//go:embed edit.md
var editDescription []byte
@@ -122,7 +127,7 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
content,
strings.TrimPrefix(filePath, edit.workingDir),
)
- p := edit.permissions.Request(
+ p, err := edit.permissions.Request(edit.ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: fsext.PathOrPrefix(filePath, edit.workingDir),
@@ -137,6 +142,9 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
},
},
)
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !p {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -214,12 +222,12 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
newContent = strings.ReplaceAll(oldContent, oldString, "")
deletionCount = strings.Count(oldContent, oldString)
if deletionCount == 0 {
- return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
+ return oldStringNotFoundErr, nil
}
} else {
index := strings.Index(oldContent, oldString)
if index == -1 {
- return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
+ return oldStringNotFoundErr, nil
}
lastIndex := strings.LastIndex(oldContent, oldString)
@@ -243,7 +251,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
strings.TrimPrefix(filePath, edit.workingDir),
)
- p := edit.permissions.Request(
+ p, err := edit.permissions.Request(edit.ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: fsext.PathOrPrefix(filePath, edit.workingDir),
@@ -258,6 +266,9 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
},
},
)
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !p {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -281,7 +292,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
}
}
if file.Content != oldContent {
- // User Manually changed the content store an intermediate version
+ // User manually changed the content; store an intermediate version
_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
if err != nil {
slog.Error("Error creating file history version", "error", err)
@@ -347,17 +358,17 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
newContent = strings.ReplaceAll(oldContent, oldString, newString)
replacementCount = strings.Count(oldContent, oldString)
if replacementCount == 0 {
- return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
+ return oldStringNotFoundErr, nil
}
} else {
index := strings.Index(oldContent, oldString)
if index == -1 {
- return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
+ return oldStringNotFoundErr, nil
}
lastIndex := strings.LastIndex(oldContent, oldString)
if index != lastIndex {
- return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
+ return oldStringMultipleMatchesErr, nil
}
newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
@@ -378,7 +389,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
strings.TrimPrefix(filePath, edit.workingDir),
)
- p := edit.permissions.Request(
+ p, err := edit.permissions.Request(edit.ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: fsext.PathOrPrefix(filePath, edit.workingDir),
@@ -393,6 +404,9 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
},
},
)
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !p {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -416,7 +430,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
}
}
if file.Content != oldContent {
- // User Manually changed the content store an intermediate version
+ // User manually changed the content; store an intermediate version
_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
@@ -55,7 +55,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
}
- p := permissions.Request(
+ p, err := permissions.Request(ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: workingDir,
@@ -66,17 +66,21 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
Params: FetchPermissionsParams(params),
},
)
-
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !p {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
+ // maxFetchTimeoutSeconds is the maximum allowed timeout for fetch requests (2 minutes)
+ const maxFetchTimeoutSeconds = 120
+
// Handle timeout with context
requestCtx := ctx
if params.Timeout > 0 {
- maxTimeout := 120 // 2 minutes
- if params.Timeout > maxTimeout {
- params.Timeout = maxTimeout
+ if params.Timeout > maxFetchTimeoutSeconds {
+ params.Timeout = maxFetchTimeoutSeconds
}
var cancel context.CancelFunc
requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
@@ -100,7 +104,10 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
}
- maxSize := int64(5 * 1024 * 1024) // 5MB
+ // maxFetchResponseSizeBytes is the maximum size of response body to read (5MB)
+ const maxFetchResponseSizeBytes = int64(5 * 1024 * 1024)
+
+ maxSize := maxFetchResponseSizeBytes
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
if err != nil {
return fantasy.NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
@@ -108,8 +115,8 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
content := string(body)
- isValidUt8 := utf8.ValidString(content)
- if !isValidUt8 {
+ validUTF8 := utf8.ValidString(content)
+ if !validUTF8 {
return fantasy.NewTextErrorResponse("Response content is not valid UTF-8"), nil
}
contentType := resp.Header.Get("Content-Type")
@@ -152,9 +159,8 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
content = "<html>\n<body>\n" + body + "\n</body>\n</html>"
}
}
- // calculate byte size of content
- contentSize := int64(len(content))
- if contentSize > MaxReadSize {
+ // truncate content if it exceeds max read size
+ if int64(len(content)) > MaxReadSize {
content = content[:MaxReadSize]
content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxReadSize)
}
@@ -19,6 +19,8 @@ import (
// BrowserUserAgent is a realistic browser User-Agent for better compatibility.
const BrowserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+var multipleNewlinesRe = regexp.MustCompile(`\n{3,}`)
+
// FetchURLAndConvert fetches a URL and converts HTML content to markdown.
func FetchURLAndConvert(ctx context.Context, client *http.Client, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
@@ -128,8 +130,7 @@ func removeNoisyElements(htmlContent string) string {
// cleanupMarkdown removes excessive whitespace and blank lines from markdown.
func cleanupMarkdown(content string) string {
// Collapse multiple blank lines into at most two.
- multipleNewlines := regexp.MustCompile(`\n{3,}`)
- content = multipleNewlines.ReplaceAllString(content, "\n\n")
+ content = multipleNewlinesRe.ReplaceAllString(content, "\n\n")
// Remove trailing whitespace from each line.
lines := strings.Split(content, "\n")
@@ -28,10 +28,17 @@ type LSPermissionsParams struct {
Depth int `json:"depth"`
}
+type NodeType string
+
+const (
+ NodeTypeFile NodeType = "file"
+ NodeTypeDirectory NodeType = "directory"
+)
+
type TreeNode struct {
Name string `json:"name"`
Path string `json:"path"`
- Type string `json:"type"` // "file" or "directory"
+ Type NodeType `json:"type"`
Children []*TreeNode `json:"children,omitempty"`
}
@@ -79,7 +86,7 @@ func NewLsTool(permissions permission.Service, workingDir string, lsConfig confi
return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory")
}
- granted := permissions.Request(
+ granted, err := permissions.Request(ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: absSearchPath,
@@ -90,7 +97,9 @@ func NewLsTool(permissions permission.Service, workingDir string, lsConfig confi
Params: LSPermissionsParams(params),
},
)
-
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -177,9 +186,9 @@ func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
isLastPart := i == len(parts)-1
isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
- nodeType := "file"
+ nodeType := NodeTypeFile
if isDir {
- nodeType = "directory"
+ nodeType = NodeTypeDirectory
}
newNode := &TreeNode{
Name: part,
@@ -226,13 +235,13 @@ func printNode(builder *strings.Builder, node *TreeNode, level int) {
indent := strings.Repeat(" ", level)
nodeName := node.Name
- if node.Type == "directory" {
+ if node.Type == NodeTypeDirectory {
nodeName = nodeName + "/"
}
fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
- if node.Type == "directory" && len(node.Children) > 0 {
+ if node.Type == NodeTypeDirectory && len(node.Children) > 0 {
for _, child := range node.Children {
printNode(builder, child, level+1)
}
@@ -89,7 +89,7 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe
return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
}
permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
- p := m.permissions.Request(
+ p, err := m.permissions.Request(ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
ToolCallID: params.ID,
@@ -100,6 +100,9 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe
Params: params.Input,
},
)
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !p {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -9,7 +9,6 @@ import (
"fmt"
"io"
"log/slog"
- "maps"
"net/http"
"os"
"os/exec"
@@ -30,6 +29,8 @@ var (
sessions = csync.NewMap[string, *mcp.ClientSession]()
states = csync.NewMap[string, ClientInfo]()
broker = pubsub.NewBroker[Event]()
+ initOnce sync.Once
+ initDone = make(chan struct{})
)
// State represents the current state of an MCP client
@@ -98,7 +99,7 @@ func SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
// GetStates returns the current state of all MCP clients
func GetStates() map[string]ClientInfo {
- return maps.Collect(states.Seq2())
+ return states.Copy()
}
// GetState returns the state of a specific MCP client
@@ -108,29 +109,28 @@ func GetState(name string) (ClientInfo, bool) {
// Close closes all MCP clients. This should be called during application shutdown.
func Close() error {
- var errs []error
var wg sync.WaitGroup
- for name, session := range sessions.Seq2() {
- wg.Go(func() {
- done := make(chan bool, 1)
- go func() {
+ done := make(chan struct{}, 1)
+ go func() {
+ for name, session := range sessions.Seq2() {
+ wg.Go(func() {
if err := session.Close(); err != nil &&
!errors.Is(err, io.EOF) &&
!errors.Is(err, context.Canceled) &&
err.Error() != "signal: killed" {
- errs = append(errs, fmt.Errorf("close mcp: %s: %w", name, err))
+ slog.Warn("Failed to shutdown MCP client", "name", name, "error", err)
}
- done <- true
- }()
- select {
- case <-done:
- case <-time.After(time.Millisecond * 250):
- }
- })
+ })
+ }
+ wg.Wait()
+ done <- struct{}{}
+ }()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
}
- wg.Wait()
broker.Shutdown()
- return errors.Join(errs...)
+ return nil
}
// Initialize initializes MCP clients based on the provided configuration.
@@ -199,6 +199,18 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
}(name, m)
}
wg.Wait()
+ initOnce.Do(func() { close(initDone) })
+}
+
+// WaitForInit blocks until MCP initialization is complete.
+// If Initialize was never called, this returns immediately.
+func WaitForInit(ctx context.Context) error {
+ select {
+ case <-initDone:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
}
func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) {
@@ -173,7 +173,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
} else {
description = fmt.Sprintf("Create file %s with %d edits", params.FilePath, editsApplied)
}
- p := edit.permissions.Request(permission.CreatePermissionRequest{
+ p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
SessionID: sessionID,
Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
ToolCallID: call.ID,
@@ -186,12 +186,15 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
NewContent: currentContent,
},
})
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !p {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
// Write the file
- err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
+ err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
if err != nil {
return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
}
@@ -314,7 +317,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
} else {
description = fmt.Sprintf("Apply %d edits to file %s", editsApplied, params.FilePath)
}
- p := edit.permissions.Request(permission.CreatePermissionRequest{
+ p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
SessionID: sessionID,
Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
ToolCallID: call.ID,
@@ -327,6 +330,9 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
NewContent: currentContent,
},
})
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !p {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -19,8 +19,8 @@ type mockPermissionService struct {
*pubsub.Broker[permission.PermissionRequest]
}
-func (m *mockPermissionService) Request(req permission.CreatePermissionRequest) bool {
- return true
+func (m *mockPermissionService) Request(ctx context.Context, req permission.CreatePermissionRequest) (bool, error) {
+ return true, nil
}
func (m *mockPermissionService) Grant(req permission.PermissionRequest) {}
@@ -4,10 +4,13 @@ import (
"context"
"fmt"
"io"
+ "math/rand/v2"
"net/http"
"net/url"
"slices"
"strings"
+ "sync"
+ "time"
"golang.org/x/net/html"
)
@@ -20,28 +23,41 @@ type SearchResult struct {
Position int
}
-// searchDuckDuckGo performs a web search using DuckDuckGo's HTML endpoint.
+var userAgents = []string{
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0",
+}
+
+var acceptLanguages = []string{
+ "en-US,en;q=0.9",
+ "en-US,en;q=0.9,es;q=0.8",
+ "en-GB,en;q=0.9,en-US;q=0.8",
+ "en-US,en;q=0.5",
+ "en-CA,en;q=0.9,en-US;q=0.8",
+}
+
func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, maxResults int) ([]SearchResult, error) {
if maxResults <= 0 {
maxResults = 10
}
- formData := url.Values{}
- formData.Set("q", query)
- formData.Set("b", "")
- formData.Set("kl", "")
+ searchURL := "https://lite.duckduckgo.com/lite/?q=" + url.QueryEscape(query)
- req, err := http.NewRequestWithContext(ctx, "POST", "https://html.duckduckgo.com/html", strings.NewReader(formData.Encode()))
+ req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("User-Agent", BrowserUserAgent)
- req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
- req.Header.Set("Accept-Language", "en-US,en;q=0.5")
- req.Header.Set("Accept-Encoding", "gzip, deflate")
- req.Header.Set("Referer", "https://duckduckgo.com/")
+ setRandomizedHeaders(req)
resp, err := client.Do(req)
if err != nil {
@@ -49,10 +65,8 @@ func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, ma
}
defer resp.Body.Close()
- // Accept both 200 (OK) and 202 (Accepted).
- // DuckDuckGo may still return 202 for rate limiting or bot detection.
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
- return nil, fmt.Errorf("search failed with status code: %d (DuckDuckGo may be rate limiting requests)", resp.StatusCode)
+ return nil, fmt.Errorf("search failed with status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
@@ -60,85 +74,90 @@ func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, ma
return nil, fmt.Errorf("failed to read response: %w", err)
}
- return parseSearchResults(string(body), maxResults)
+ return parseLiteSearchResults(string(body), maxResults)
}
-// parseSearchResults extracts search results from DuckDuckGo HTML response.
-func parseSearchResults(htmlContent string, maxResults int) ([]SearchResult, error) {
+func setRandomizedHeaders(req *http.Request) {
+ req.Header.Set("User-Agent", userAgents[rand.IntN(len(userAgents))])
+ req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
+ req.Header.Set("Accept-Language", acceptLanguages[rand.IntN(len(acceptLanguages))])
+ req.Header.Set("Accept-Encoding", "identity")
+ req.Header.Set("Connection", "keep-alive")
+ req.Header.Set("Upgrade-Insecure-Requests", "1")
+ req.Header.Set("Sec-Fetch-Dest", "document")
+ req.Header.Set("Sec-Fetch-Mode", "navigate")
+ req.Header.Set("Sec-Fetch-Site", "none")
+ req.Header.Set("Sec-Fetch-User", "?1")
+ req.Header.Set("Cache-Control", "max-age=0")
+ if rand.IntN(2) == 0 {
+ req.Header.Set("DNT", "1")
+ }
+}
+
+func parseLiteSearchResults(htmlContent string, maxResults int) ([]SearchResult, error) {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return nil, fmt.Errorf("failed to parse HTML: %w", err)
}
var results []SearchResult
- var traverse func(*html.Node)
+ var currentResult *SearchResult
+ var traverse func(*html.Node)
traverse = func(n *html.Node) {
- if n.Type == html.ElementNode && n.Data == "div" && hasClass(n, "result") {
- result := extractResult(n)
- if result != nil && result.Link != "" && !strings.Contains(result.Link, "y.js") {
- result.Position = len(results) + 1
- results = append(results, *result)
- if len(results) >= maxResults {
- return
+ if n.Type == html.ElementNode {
+ if n.Data == "a" && hasClass(n, "result-link") {
+ if currentResult != nil && currentResult.Link != "" {
+ currentResult.Position = len(results) + 1
+ results = append(results, *currentResult)
+ if len(results) >= maxResults {
+ return
+ }
+ }
+ currentResult = &SearchResult{Title: getTextContent(n)}
+ for _, attr := range n.Attr {
+ if attr.Key == "href" {
+ currentResult.Link = cleanDuckDuckGoURL(attr.Val)
+ break
+ }
}
}
+ if n.Data == "td" && hasClass(n, "result-snippet") && currentResult != nil {
+ currentResult.Snippet = getTextContent(n)
+ }
}
- for c := n.FirstChild; c != nil && len(results) < maxResults; c = c.NextSibling {
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ if len(results) >= maxResults {
+ return
+ }
traverse(c)
}
}
traverse(doc)
+
+ if currentResult != nil && currentResult.Link != "" && len(results) < maxResults {
+ currentResult.Position = len(results) + 1
+ results = append(results, *currentResult)
+ }
+
return results, nil
}
-// hasClass checks if an HTML node has a specific class.
func hasClass(n *html.Node, class string) bool {
for _, attr := range n.Attr {
if attr.Key == "class" {
- return slices.Contains(strings.Fields(attr.Val), class)
- }
- }
- return false
-}
-
-// extractResult extracts a search result from a result div node.
-func extractResult(n *html.Node) *SearchResult {
- result := &SearchResult{}
-
- var traverse func(*html.Node)
- traverse = func(node *html.Node) {
- if node.Type == html.ElementNode {
- // Look for title link.
- if node.Data == "a" && hasClass(node, "result__a") {
- result.Title = getTextContent(node)
- for _, attr := range node.Attr {
- if attr.Key == "href" {
- result.Link = cleanDuckDuckGoURL(attr.Val)
- break
- }
- }
- }
- // Look for snippet.
- if node.Data == "a" && hasClass(node, "result__snippet") {
- result.Snippet = getTextContent(node)
+ if slices.Contains(strings.Fields(attr.Val), class) {
+ return true
}
}
- for c := node.FirstChild; c != nil; c = c.NextSibling {
- traverse(c)
- }
}
-
- traverse(n)
- return result
+ return false
}
-// getTextContent extracts all text content from a node and its children.
func getTextContent(n *html.Node) string {
var text strings.Builder
var traverse func(*html.Node)
-
traverse = func(node *html.Node) {
if node.Type == html.TextNode {
text.WriteString(node.Data)
@@ -147,22 +166,18 @@ func getTextContent(n *html.Node) string {
traverse(c)
}
}
-
traverse(n)
return strings.TrimSpace(text.String())
}
-// cleanDuckDuckGoURL extracts the actual URL from DuckDuckGo's redirect URL.
func cleanDuckDuckGoURL(rawURL string) string {
if strings.HasPrefix(rawURL, "//duckduckgo.com/l/?uddg=") {
- // Extract the actual URL from the redirect.
if idx := strings.Index(rawURL, "uddg="); idx != -1 {
encoded := rawURL[idx+5:]
if ampIdx := strings.Index(encoded, "&"); ampIdx != -1 {
encoded = encoded[:ampIdx]
}
- decoded, err := url.QueryUnescape(encoded)
- if err == nil {
+ if decoded, err := url.QueryUnescape(encoded); err == nil {
return decoded
}
}
@@ -170,20 +185,35 @@ func cleanDuckDuckGoURL(rawURL string) string {
return rawURL
}
-// formatSearchResults formats search results for LLM consumption.
func formatSearchResults(results []SearchResult) string {
if len(results) == 0 {
- return "No results were found for your search query. This could be due to DuckDuckGo's bot detection or the query returned no matches. Please try rephrasing your search or try again in a few minutes."
+ return "No results found. Try rephrasing your search."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Found %d search results:\n\n", len(results)))
-
for _, result := range results {
sb.WriteString(fmt.Sprintf("%d. %s\n", result.Position, result.Title))
sb.WriteString(fmt.Sprintf(" URL: %s\n", result.Link))
sb.WriteString(fmt.Sprintf(" Summary: %s\n\n", result.Snippet))
}
-
return sb.String()
}
+
+var (
+ lastSearchMu sync.Mutex
+ lastSearchTime time.Time
+)
+
+// maybeDelaySearch adds a random delay if the last search was recent.
+func maybeDelaySearch() {
+ lastSearchMu.Lock()
+ defer lastSearchMu.Unlock()
+
+ minGap := time.Duration(500+rand.IntN(1500)) * time.Millisecond
+ elapsed := time.Since(lastSearchTime)
+ if elapsed < minGap {
+ time.Sleep(minGap - elapsed)
+ }
+ lastSearchTime = time.Now()
+}
@@ -35,13 +35,6 @@ type ViewPermissionsParams struct {
Limit int `json:"limit"`
}
-type viewTool struct {
- lspClients *csync.Map[string, *lsp.Client]
- workingDir string
- permissions permission.Service
- skillsPaths []string
-}
-
type ViewResponseMetadata struct {
FilePath string `json:"file_path"`
Content string `json:"content"`
@@ -88,7 +81,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
}
- granted := permissions.Request(
+ granted, err := permissions.Request(ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: absFilePath,
@@ -99,7 +92,9 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
Params: ViewPermissionsParams(params),
},
)
-
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -3,6 +3,7 @@ package tools
import (
"context"
_ "embed"
+ "log/slog"
"net/http"
"time"
@@ -41,7 +42,9 @@ func NewWebSearchTool(client *http.Client) fantasy.AgentTool {
maxResults = 20
}
+ maybeDelaySearch()
results, err := searchDuckDuckGo(ctx, client, params.Query, maxResults)
+ slog.Debug("Web search completed", "query", params.Query, "results", len(results), "err", err)
if err != nil {
return fantasy.NewTextErrorResponse("Failed to search: " + err.Error()), nil
}
@@ -36,13 +36,6 @@ type WritePermissionsParams struct {
NewContent string `json:"new_content,omitempty"`
}
-type writeTool struct {
- lspClients *csync.Map[string, *lsp.Client]
- permissions permission.Service
- files history.Service
- workingDir string
-}
-
type WriteResponseMetadata struct {
Diff string `json:"diff"`
Additions int `json:"additions"`
@@ -111,7 +104,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
strings.TrimPrefix(filePath, workingDir),
)
- p := permissions.Request(
+ p, err := permissions.Request(ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: fsext.PathOrPrefix(filePath, workingDir),
@@ -126,6 +119,9 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
},
},
)
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !p {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -145,7 +141,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
}
}
if file.Content != oldContent {
- // User Manually changed the content store an intermediate version
+ // User manually changed the content; store an intermediate version
_, err = files.CreateVersion(ctx, sessionID, filePath, oldContent)
if err != nil {
slog.Error("Error creating file history version", "error", err)
@@ -392,25 +392,31 @@ func (app *App) Subscribe(program *tea.Program) {
func (app *App) Shutdown() {
start := time.Now()
defer func() { slog.Info("Shutdown took " + time.Since(start).String()) }()
- var wg sync.WaitGroup
+
+ // First, cancel all agents and wait for them to finish. This must complete
+ // before closing the DB so agents can finish writing their state.
if app.AgentCoordinator != nil {
- wg.Go(func() {
- app.AgentCoordinator.CancelAll()
- })
+ app.AgentCoordinator.CancelAll()
}
+ // Now run remaining cleanup tasks in parallel.
+ var wg sync.WaitGroup
+
// Kill all background shells.
wg.Go(func() {
shell.GetBackgroundShellManager().KillAll()
})
// Shutdown all LSP clients.
+ shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
+ defer cancel()
for name, client := range app.LSPClients.Seq2() {
wg.Go(func() {
- shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
- defer cancel()
- if err := client.Close(shutdownCtx); err != nil {
- slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
+ if err := client.Close(shutdownCtx); err != nil &&
+ !errors.Is(err, io.EOF) &&
+ !errors.Is(err, context.Canceled) &&
+ err.Error() != "signal: killed" {
+ slog.Warn("Failed to shutdown LSP client", "name", name, "error", err)
}
})
}
@@ -67,7 +67,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
lspClient.SetServerState(lsp.StateError)
updateLSPState(name, lsp.StateError, err, lspClient, 0)
} else {
- // Server reached a ready state scuccessfully.
+ // Server reached a ready state successfully.
slog.Debug("LSP server is ready", "name", name)
lspClient.SetServerState(lsp.StateReady)
updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
@@ -2,7 +2,6 @@ package app
import (
"context"
- "maps"
"time"
"github.com/charmbracelet/crush/internal/csync"
@@ -49,7 +48,7 @@ func SubscribeLSPEvents(ctx context.Context) <-chan pubsub.Event[LSPEvent] {
// GetLSPStates returns the current state of all LSP clients
func GetLSPStates() map[string]LSPClientInfo {
- return maps.Collect(lspStates.Seq2())
+ return lspStates.Copy()
}
// GetLSPState returns the state of a specific LSP client
@@ -6,14 +6,12 @@ import (
"fmt"
"os"
"os/signal"
- "strings"
"charm.land/lipgloss/v2"
"github.com/atotto/clipboard"
hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/oauth"
- "github.com/charmbracelet/crush/internal/oauth/claude"
"github.com/charmbracelet/crush/internal/oauth/copilot"
"github.com/charmbracelet/crush/internal/oauth/hyper"
"github.com/pkg/browser"
@@ -26,21 +24,16 @@ var loginCmd = &cobra.Command{
Short: "Login Crush to a platform",
Long: `Login Crush to a specified platform.
The platform should be provided as an argument.
-Available platforms are: hyper, claude, copilot.`,
+Available platforms are: hyper, copilot.`,
Example: `
# Authenticate with Charm Hyper
crush login
-# Authenticate with Claude Code Max
-crush login claude
-
# Authenticate with GitHub Copilot
crush login copilot
`,
ValidArgs: []cobra.Completion{
"hyper",
- "claude",
- "anthropic",
"copilot",
"github",
"github-copilot",
@@ -60,8 +53,6 @@ crush login copilot
switch provider {
case "hyper":
return loginHyper()
- case "anthropic", "claude":
- return loginClaude()
case "copilot", "github", "github-copilot":
return loginCopilot()
default:
@@ -133,60 +124,6 @@ func loginHyper() error {
return nil
}
-func loginClaude() error {
- ctx := getLoginContext()
-
- cfg := config.Get()
- if cfg.HasConfigField("providers.anthropic.oauth") {
- fmt.Println("You are already logged in to Claude.")
- return nil
- }
-
- verifier, challenge, err := claude.GetChallenge()
- if err != nil {
- return err
- }
- url, err := claude.AuthorizeURL(verifier, challenge)
- if err != nil {
- return err
- }
- fmt.Println("Open the following URL and follow the instructions to authenticate with Claude Code Max:")
- fmt.Println()
- fmt.Println(lipgloss.NewStyle().Hyperlink(url, "id=claude").Render(url))
- fmt.Println()
- fmt.Println("Press enter to continue...")
- if _, err := fmt.Scanln(); err != nil {
- return err
- }
-
- fmt.Println("Now paste and code from Anthropic and press enter...")
- fmt.Println()
- fmt.Print("> ")
- var code string
- for code == "" {
- _, _ = fmt.Scanln(&code)
- code = strings.TrimSpace(code)
- }
-
- fmt.Println()
- fmt.Println("Exchanging authorization code...")
- token, err := claude.ExchangeToken(ctx, code, verifier)
- if err != nil {
- return err
- }
-
- if err := cmp.Or(
- cfg.SetConfigField("providers.anthropic.api_key", token.AccessToken),
- cfg.SetConfigField("providers.anthropic.oauth", token),
- ); err != nil {
- return err
- }
-
- fmt.Println()
- fmt.Println("You're now authenticated with Claude Code Max!")
- return nil
-}
-
func loginCopilot() error {
ctx := getLoginContext()
@@ -231,7 +168,7 @@ func loginCopilot() error {
fmt.Println()
fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL))
fmt.Println()
- fmt.Println("You may be able to request free access if elegible. For more information, see:")
+ fmt.Println("You may be able to request free access if eligible. For more information, see:")
fmt.Println()
fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL))
}
@@ -1,8 +1,6 @@
package config
import (
- "io"
- "strings"
"testing"
"github.com/stretchr/testify/require"
@@ -83,7 +81,7 @@ func TestAttributionMigration(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
- cfg, err := loadFromReaders([]io.Reader{strings.NewReader(tt.configJSON)})
+ cfg, err := loadFromBytes([][]byte{[]byte(tt.configJSON)})
require.NoError(t, err)
cfg.setDefaults(t.TempDir(), "")
@@ -19,7 +19,6 @@ import (
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/env"
"github.com/charmbracelet/crush/internal/oauth"
- "github.com/charmbracelet/crush/internal/oauth/claude"
"github.com/charmbracelet/crush/internal/oauth/copilot"
"github.com/charmbracelet/crush/internal/oauth/hyper"
"github.com/invopop/jsonschema"
@@ -155,21 +154,6 @@ func (pc *ProviderConfig) ToProvider() catwalk.Provider {
return provider
}
-func (pc *ProviderConfig) SetupClaudeCode() {
- pc.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude."
- pc.ExtraHeaders["anthropic-version"] = "2023-06-01"
-
- value := pc.ExtraHeaders["anthropic-beta"]
- const want = "oauth-2025-04-20"
- if !strings.Contains(value, want) {
- if value != "" {
- value += ","
- }
- value += want
- }
- pc.ExtraHeaders["anthropic-beta"] = value
-}
-
func (pc *ProviderConfig) SetupGitHubCopilot() {
maps.Copy(pc.ExtraHeaders, copilot.Headers())
}
@@ -264,6 +248,7 @@ type Options struct {
DataDirectory string `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd
DisabledTools []string `json:"disabled_tools,omitempty" jsonschema:"description=List of built-in tools to disable and hide from the agent,example=bash,example=sourcegraph"`
DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
+ DisableDefaultProviders bool `json:"disable_default_providers,omitempty" jsonschema:"description=Ignore all default/embedded providers. When enabled, providers must be fully specified in the config file with base_url, models, and api_key - no merging with defaults occurs,default=false"`
Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
@@ -522,6 +507,25 @@ func (c *Config) SetConfigField(key string, value any) error {
return nil
}
+func (c *Config) RemoveConfigField(key string) error {
+ data, err := os.ReadFile(c.dataConfigDir)
+ if err != nil {
+ return fmt.Errorf("failed to read config file: %w", err)
+ }
+
+ newValue, err := sjson.Delete(string(data), key)
+ if err != nil {
+ return fmt.Errorf("failed to delete config field %s: %w", key, err)
+ }
+ if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil {
+ return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err)
+ }
+ if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil {
+ return fmt.Errorf("failed to write config file: %w", err)
+ }
+ return nil
+}
+
// RefreshOAuthToken refreshes the OAuth token for the given provider.
func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
providerConfig, exists := c.Providers.Get(providerID)
@@ -536,8 +540,6 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
var newToken *oauth.Token
var refreshErr error
switch providerID {
- case string(catwalk.InferenceProviderAnthropic):
- newToken, refreshErr = claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
case string(catwalk.InferenceProviderCopilot):
newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
case hyperp.Name:
@@ -554,8 +556,6 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
providerConfig.APIKey = newToken.AccessToken
switch providerID {
- case string(catwalk.InferenceProviderAnthropic):
- providerConfig.SetupClaudeCode()
case string(catwalk.InferenceProviderCopilot):
providerConfig.SetupGitHubCopilot()
}
@@ -594,8 +594,6 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
providerConfig.APIKey = v.AccessToken
providerConfig.OAuthToken = v
switch providerID {
- case string(catwalk.InferenceProviderAnthropic):
- providerConfig.SetupClaudeCode()
case string(catwalk.InferenceProviderCopilot):
providerConfig.SetupGitHubCopilot()
}
@@ -809,21 +807,21 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
for k, v := range c.ExtraHeaders {
req.Header.Set(k, v)
}
- b, err := client.Do(req)
+ resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to create request for provider %s: %w", c.ID, err)
}
+ defer resp.Body.Close()
if c.ID == string(catwalk.InferenceProviderZAI) {
- if b.StatusCode == http.StatusUnauthorized {
- // for z.ai just check if the http response is not 401
- return fmt.Errorf("failed to connect to provider %s: %s", c.ID, b.Status)
+ if resp.StatusCode == http.StatusUnauthorized {
+ // For z.ai just check if the http response is not 401.
+ return fmt.Errorf("failed to connect to provider %s: %s", c.ID, resp.Status)
}
} else {
- if b.StatusCode != http.StatusOK {
- return fmt.Errorf("failed to connect to provider %s: %s", c.ID, b.Status)
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to connect to provider %s: %s", c.ID, resp.Status)
}
}
- _ = b.Body.Close()
return nil
}
@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"fmt"
- "io"
"log/slog"
"maps"
"os"
@@ -25,25 +24,11 @@ import (
"github.com/charmbracelet/crush/internal/home"
"github.com/charmbracelet/crush/internal/log"
powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
+ "github.com/qjebbs/go-jsons"
)
const defaultCatwalkURL = "https://catwalk.charm.sh"
-// LoadReader config via io.Reader.
-func LoadReader(fd io.Reader) (*Config, error) {
- data, err := io.ReadAll(fd)
- if err != nil {
- return nil, err
- }
-
- var config Config
- err = json.Unmarshal(data, &config)
- if err != nil {
- return nil, err
- }
- return &config, err
-}
-
// Load loads the configuration from the default paths.
func Load(workingDir, dataDir string, debug bool) (*Config, error) {
configPaths := lookupConfigs(workingDir)
@@ -137,6 +122,14 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
restore := PushPopCrushEnv()
defer restore()
+ // When disable_default_providers is enabled, skip all default/embedded
+ // providers entirely. Users must fully specify any providers they want.
+ // We skip to the custom provider validation loop which handles all
+ // user-configured providers uniformly.
+ if c.Options.DisableDefaultProviders {
+ knownProviders = nil
+ }
+
for _, p := range knownProviders {
knownProviderNames[string(p.ID)] = true
config, configExists := c.Providers.Get(string(p.ID))
@@ -184,6 +177,14 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
if len(config.ExtraHeaders) > 0 {
maps.Copy(headers, config.ExtraHeaders)
}
+ for k, v := range headers {
+ resolved, err := resolver.ResolveValue(v)
+ if err != nil {
+ slog.Error("Could not resolve provider header", "err", err.Error())
+ continue
+ }
+ headers[k] = resolved
+ }
prepared := ProviderConfig{
ID: string(p.ID),
Name: p.Name,
@@ -202,11 +203,12 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
switch {
case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
- prepared.SetupClaudeCode()
- case p.ID == catwalk.InferenceProviderCopilot:
- if config.OAuthToken != nil {
- prepared.SetupGitHubCopilot()
- }
+ // Claude Code subscription is not supported anymore. Remove to show onboarding.
+ c.RemoveConfigField("providers.anthropic")
+ c.Providers.Del(string(p.ID))
+ continue
+ case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
+ prepared.SetupGitHubCopilot()
}
switch p.ID {
@@ -313,6 +315,15 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
continue
}
+ for k, v := range providerConfig.ExtraHeaders {
+ resolved, err := resolver.ResolveValue(v)
+ if err != nil {
+ slog.Error("Could not resolve provider header", "err", err.Error())
+ continue
+ }
+ providerConfig.ExtraHeaders[k] = resolved
+ }
+
c.Providers.Set(id, providerConfig)
}
return nil
@@ -376,6 +387,10 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
}
+ if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok {
+ c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str)
+ }
+
if c.Options.Attribution == nil {
c.Options.Attribution = &Attribution{
TrailerStyle: TrailerStyleAssistedBy,
@@ -631,35 +646,39 @@ func lookupConfigs(cwd string) []string {
}
func loadFromConfigPaths(configPaths []string) (*Config, error) {
- var configs []io.Reader
+ var configs [][]byte
for _, path := range configPaths {
- fd, err := os.Open(path)
+ data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, fmt.Errorf("failed to open config file %s: %w", path, err)
}
- defer fd.Close()
-
- configs = append(configs, fd)
+ if len(data) == 0 {
+ continue
+ }
+ configs = append(configs, data)
}
- return loadFromReaders(configs)
+ return loadFromBytes(configs)
}
-func loadFromReaders(readers []io.Reader) (*Config, error) {
- if len(readers) == 0 {
+func loadFromBytes(configs [][]byte) (*Config, error) {
+ if len(configs) == 0 {
return &Config{}, nil
}
- merged, err := Merge(readers)
+ data, err := jsons.Merge(configs)
if err != nil {
- return nil, fmt.Errorf("failed to merge configuration readers: %w", err)
+ return nil, err
}
-
- return LoadReader(merged)
+ var config Config
+ if err := json.Unmarshal(data, &config); err != nil {
+ return nil, err
+ }
+ return &config, nil
}
func hasVertexCredentials(env env.Env) bool {
@@ -0,0 +1,103 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func BenchmarkLoadFromConfigPaths(b *testing.B) {
+ // Create temp config files with realistic content.
+ tmpDir := b.TempDir()
+
+ globalConfig := filepath.Join(tmpDir, "global.json")
+ localConfig := filepath.Join(tmpDir, "local.json")
+
+ globalContent := []byte(`{
+ "providers": {
+ "openai": {
+ "api_key": "$OPENAI_API_KEY",
+ "base_url": "https://api.openai.com/v1"
+ },
+ "anthropic": {
+ "api_key": "$ANTHROPIC_API_KEY",
+ "base_url": "https://api.anthropic.com"
+ }
+ },
+ "options": {
+ "tui": {
+ "theme": "dark"
+ }
+ }
+ }`)
+
+ localContent := []byte(`{
+ "providers": {
+ "openai": {
+ "api_key": "sk-override-key"
+ }
+ },
+ "options": {
+ "context_paths": ["README.md", "AGENTS.md"]
+ }
+ }`)
+
+ if err := os.WriteFile(globalConfig, globalContent, 0o644); err != nil {
+ b.Fatal(err)
+ }
+ if err := os.WriteFile(localConfig, localContent, 0o644); err != nil {
+ b.Fatal(err)
+ }
+
+ configPaths := []string{globalConfig, localConfig}
+
+ b.ReportAllocs()
+ for b.Loop() {
+ _, err := loadFromConfigPaths(configPaths)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkLoadFromConfigPaths_MissingFiles(b *testing.B) {
+ // Test with mix of existing and non-existing paths.
+ tmpDir := b.TempDir()
+
+ existingConfig := filepath.Join(tmpDir, "exists.json")
+ content := []byte(`{"options": {"tui": {"theme": "dark"}}}`)
+ if err := os.WriteFile(existingConfig, content, 0o644); err != nil {
+ b.Fatal(err)
+ }
+
+ configPaths := []string{
+ filepath.Join(tmpDir, "nonexistent1.json"),
+ existingConfig,
+ filepath.Join(tmpDir, "nonexistent2.json"),
+ }
+
+ b.ReportAllocs()
+ for b.Loop() {
+ _, err := loadFromConfigPaths(configPaths)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkLoadFromConfigPaths_Empty(b *testing.B) {
+ // Test with no config files.
+ tmpDir := b.TempDir()
+ configPaths := []string{
+ filepath.Join(tmpDir, "nonexistent1.json"),
+ filepath.Join(tmpDir, "nonexistent2.json"),
+ }
+
+ b.ReportAllocs()
+ for b.Loop() {
+ _, err := loadFromConfigPaths(configPaths)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
@@ -5,7 +5,6 @@ import (
"log/slog"
"os"
"path/filepath"
- "strings"
"testing"
"github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -22,12 +21,12 @@ func TestMain(m *testing.M) {
os.Exit(exitVal)
}
-func TestConfig_LoadFromReaders(t *testing.T) {
- data1 := strings.NewReader(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`)
- data2 := strings.NewReader(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`)
- data3 := strings.NewReader(`{"providers": {"openai": {}}}`)
+func TestConfig_LoadFromBytes(t *testing.T) {
+ data1 := []byte(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`)
+ data2 := []byte(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`)
+ data3 := []byte(`{"providers": {"openai": {}}}`)
- loadedConfig, err := loadFromReaders([]io.Reader{data1, data2, data3})
+ loadedConfig, err := loadFromBytes([][]byte{data1, data2, data3})
require.NoError(t, err)
require.NotNil(t, loadedConfig)
@@ -1095,6 +1094,217 @@ func TestConfig_defaultModelSelection(t *testing.T) {
})
}
+func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
+ t.Run("when enabled, ignores all default providers and requires full specification", func(t *testing.T) {
+ knownProviders := []catwalk.Provider{
+ {
+ ID: "openai",
+ APIKey: "$OPENAI_API_KEY",
+ APIEndpoint: "https://api.openai.com/v1",
+ Models: []catwalk.Model{{
+ ID: "gpt-4",
+ }},
+ },
+ }
+
+ // User references openai but doesn't fully specify it (no base_url, no
+ // models). This should be rejected because disable_default_providers
+ // treats all providers as custom.
+ cfg := &Config{
+ Options: &Options{
+ DisableDefaultProviders: true,
+ },
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
+ "openai": {
+ APIKey: "$OPENAI_API_KEY",
+ },
+ }),
+ }
+ cfg.setDefaults("/tmp", "")
+
+ env := env.NewFromMap(map[string]string{
+ "OPENAI_API_KEY": "test-key",
+ })
+ resolver := NewEnvironmentVariableResolver(env)
+ err := cfg.configureProviders(env, resolver, knownProviders)
+ require.NoError(t, err)
+
+ // openai should NOT be present because it lacks base_url and models.
+ require.Equal(t, 0, cfg.Providers.Len())
+ _, exists := cfg.Providers.Get("openai")
+ require.False(t, exists, "openai should not be present without full specification")
+ })
+
+ t.Run("when enabled, fully specified providers work", func(t *testing.T) {
+ knownProviders := []catwalk.Provider{
+ {
+ ID: "openai",
+ APIKey: "$OPENAI_API_KEY",
+ APIEndpoint: "https://api.openai.com/v1",
+ Models: []catwalk.Model{{
+ ID: "gpt-4",
+ }},
+ },
+ }
+
+ // User fully specifies their provider.
+ cfg := &Config{
+ Options: &Options{
+ DisableDefaultProviders: true,
+ },
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
+ "my-llm": {
+ APIKey: "$MY_API_KEY",
+ BaseURL: "https://my-llm.example.com/v1",
+ Models: []catwalk.Model{{
+ ID: "my-model",
+ }},
+ },
+ }),
+ }
+ cfg.setDefaults("/tmp", "")
+
+ env := env.NewFromMap(map[string]string{
+ "MY_API_KEY": "test-key",
+ "OPENAI_API_KEY": "test-key",
+ })
+ resolver := NewEnvironmentVariableResolver(env)
+ err := cfg.configureProviders(env, resolver, knownProviders)
+ require.NoError(t, err)
+
+ // Only fully specified provider should be present.
+ require.Equal(t, 1, cfg.Providers.Len())
+ provider, exists := cfg.Providers.Get("my-llm")
+ require.True(t, exists, "my-llm should be present")
+ require.Equal(t, "https://my-llm.example.com/v1", provider.BaseURL)
+ require.Len(t, provider.Models, 1)
+
+ // Default openai should NOT be present.
+ _, exists = cfg.Providers.Get("openai")
+ require.False(t, exists, "openai should not be present")
+ })
+
+ t.Run("when disabled, includes all known providers with valid credentials", func(t *testing.T) {
+ knownProviders := []catwalk.Provider{
+ {
+ ID: "openai",
+ APIKey: "$OPENAI_API_KEY",
+ APIEndpoint: "https://api.openai.com/v1",
+ Models: []catwalk.Model{{
+ ID: "gpt-4",
+ }},
+ },
+ {
+ ID: "anthropic",
+ APIKey: "$ANTHROPIC_API_KEY",
+ APIEndpoint: "https://api.anthropic.com/v1",
+ Models: []catwalk.Model{{
+ ID: "claude-3",
+ }},
+ },
+ }
+
+ // User only configures openai, both API keys are available, but option
+ // is disabled.
+ cfg := &Config{
+ Options: &Options{
+ DisableDefaultProviders: false,
+ },
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
+ "openai": {
+ APIKey: "$OPENAI_API_KEY",
+ },
+ }),
+ }
+ cfg.setDefaults("/tmp", "")
+
+ env := env.NewFromMap(map[string]string{
+ "OPENAI_API_KEY": "test-key",
+ "ANTHROPIC_API_KEY": "test-key",
+ })
+ resolver := NewEnvironmentVariableResolver(env)
+ err := cfg.configureProviders(env, resolver, knownProviders)
+ require.NoError(t, err)
+
+ // Both providers should be present.
+ require.Equal(t, 2, cfg.Providers.Len())
+ _, exists := cfg.Providers.Get("openai")
+ require.True(t, exists, "openai should be present")
+ _, exists = cfg.Providers.Get("anthropic")
+ require.True(t, exists, "anthropic should be present")
+ })
+
+ t.Run("when enabled, provider missing models is rejected", func(t *testing.T) {
+ cfg := &Config{
+ Options: &Options{
+ DisableDefaultProviders: true,
+ },
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
+ "my-llm": {
+ APIKey: "test-key",
+ BaseURL: "https://my-llm.example.com/v1",
+ Models: []catwalk.Model{}, // No models.
+ },
+ }),
+ }
+ cfg.setDefaults("/tmp", "")
+
+ env := env.NewFromMap(map[string]string{})
+ resolver := NewEnvironmentVariableResolver(env)
+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
+ require.NoError(t, err)
+
+ // Provider should be rejected for missing models.
+ require.Equal(t, 0, cfg.Providers.Len())
+ })
+
+ t.Run("when enabled, provider missing base_url is rejected", func(t *testing.T) {
+ cfg := &Config{
+ Options: &Options{
+ DisableDefaultProviders: true,
+ },
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
+ "my-llm": {
+ APIKey: "test-key",
+ Models: []catwalk.Model{{ID: "model"}},
+ // No BaseURL.
+ },
+ }),
+ }
+ cfg.setDefaults("/tmp", "")
+
+ env := env.NewFromMap(map[string]string{})
+ resolver := NewEnvironmentVariableResolver(env)
+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
+ require.NoError(t, err)
+
+ // Provider should be rejected for missing base_url.
+ require.Equal(t, 0, cfg.Providers.Len())
+ })
+}
+
+func TestConfig_setDefaultsDisableDefaultProvidersEnvVar(t *testing.T) {
+ t.Run("sets option from environment variable", func(t *testing.T) {
+ t.Setenv("CRUSH_DISABLE_DEFAULT_PROVIDERS", "true")
+
+ cfg := &Config{}
+ cfg.setDefaults("/tmp", "")
+
+ require.True(t, cfg.Options.DisableDefaultProviders)
+ })
+
+ t.Run("does not override when env var is not set", func(t *testing.T) {
+ cfg := &Config{
+ Options: &Options{
+ DisableDefaultProviders: true,
+ },
+ }
+ cfg.setDefaults("/tmp", "")
+
+ require.True(t, cfg.Options.DisableDefaultProviders)
+ })
+}
+
func TestConfig_configureSelectedModels(t *testing.T) {
t.Run("should override defaults", func(t *testing.T) {
knownProviders := []catwalk.Provider{
@@ -1,16 +0,0 @@
-package config
-
-import (
- "bytes"
- "io"
-
- "github.com/qjebbs/go-jsons"
-)
-
-func Merge(data []io.Reader) (io.Reader, error) {
- got, err := jsons.Merge(data)
- if err != nil {
- return nil, err
- }
- return bytes.NewReader(got), nil
-}
@@ -1,27 +0,0 @@
-package config
-
-import (
- "io"
- "strings"
- "testing"
-)
-
-func TestMerge(t *testing.T) {
- data1 := strings.NewReader(`{"foo": "bar"}`)
- data2 := strings.NewReader(`{"baz": "qux"}`)
-
- merged, err := Merge([]io.Reader{data1, data2})
- if err != nil {
- t.Fatalf("expected no error, got %v", err)
- }
-
- expected := `{"foo":"bar","baz":"qux"}`
- got, err := io.ReadAll(merged)
- if err != nil {
- t.Fatalf("expected no error reading merged data, got %v", err)
- }
-
- if string(got) != expected {
- t.Errorf("expected %s, got %s", expected, string(got))
- }
-}
@@ -33,8 +33,8 @@ func NewLazyMap[K comparable, V any](load func() map[K]V) *Map[K, V] {
m := &Map[K, V]{}
m.mu.Lock()
go func() {
+ defer m.mu.Unlock()
m.inner = load()
- m.mu.Unlock()
}()
return m
}
@@ -96,12 +96,16 @@ func (m *Map[K, V]) Take(key K) (V, bool) {
return v, ok
}
+// Copy returns a copy of the inner map.
+func (m *Map[K, V]) Copy() map[K]V {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return maps.Clone(m.inner)
+}
+
// Seq2 returns an iter.Seq2 that yields key-value pairs from the map.
func (m *Map[K, V]) Seq2() iter.Seq2[K, V] {
- dst := make(map[K]V)
- m.mu.RLock()
- maps.Copy(dst, m.inner)
- m.mu.RUnlock()
+ dst := m.Copy()
return func(yield func(K, V) bool) {
for k, v := range dst {
if !yield(k, v) {
@@ -4,6 +4,7 @@ import (
"encoding/json"
"maps"
"sync"
+ "sync/atomic"
"testing"
"testing/synctest"
"time"
@@ -46,12 +47,12 @@ func TestNewLazyMap(t *testing.T) {
waiter := sync.Mutex{}
waiter.Lock()
- loadCalled := false
+ var loadCalled atomic.Bool
loadFunc := func() map[string]int {
waiter.Lock()
defer waiter.Unlock()
- loadCalled = true
+ loadCalled.Store(true)
return map[string]int{
"key1": 1,
"key2": 2,
@@ -63,7 +64,7 @@ func TestNewLazyMap(t *testing.T) {
waiter.Unlock() // Allow the load function to proceed
time.Sleep(100 * time.Millisecond)
- require.True(t, loadCalled)
+ require.True(t, loadCalled.Load())
require.Equal(t, 2, m.Len())
value, ok := m.Get("key1")
@@ -2,7 +2,6 @@ package csync
import (
"iter"
- "slices"
"sync"
)
@@ -63,24 +62,6 @@ func (s *Slice[T]) Append(items ...T) {
s.inner = append(s.inner, items...)
}
-// Prepend adds an element to the beginning of the slice.
-func (s *Slice[T]) Prepend(item T) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.inner = append([]T{item}, s.inner...)
-}
-
-// Delete removes the element at the specified index.
-func (s *Slice[T]) Delete(index int) bool {
- s.mu.Lock()
- defer s.mu.Unlock()
- if index < 0 || index >= len(s.inner) {
- return false
- }
- s.inner = slices.Delete(s.inner, index, index+1)
- return true
-}
-
// Get returns the element at the specified index.
func (s *Slice[T]) Get(index int) (T, bool) {
s.mu.RLock()
@@ -92,17 +73,6 @@ func (s *Slice[T]) Get(index int) (T, bool) {
return s.inner[index], true
}
-// Set updates the element at the specified index.
-func (s *Slice[T]) Set(index int, item T) bool {
- s.mu.Lock()
- defer s.mu.Unlock()
- if index < 0 || index >= len(s.inner) {
- return false
- }
- s.inner[index] = item
- return true
-}
-
// Len returns the number of elements in the slice.
func (s *Slice[T]) Len() int {
s.mu.RLock()
@@ -131,10 +101,7 @@ func (s *Slice[T]) Seq() iter.Seq[T] {
// Seq2 returns an iterator that yields index-value pairs from the slice.
func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
- s.mu.RLock()
- items := make([]T, len(s.inner))
- copy(items, s.inner)
- s.mu.RUnlock()
+ items := s.Copy()
return func(yield func(int, T) bool) {
for i, v := range items {
if !yield(i, v) {
@@ -143,3 +110,12 @@ func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
}
}
}
+
+// Copy returns a copy of the inner slice.
+func (s *Slice[T]) Copy() []T {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ items := make([]T, len(s.inner))
+ copy(items, s.inner)
+ return items
+}
@@ -109,44 +109,6 @@ func TestSlice(t *testing.T) {
require.Equal(t, "world", val)
})
- t.Run("Prepend", func(t *testing.T) {
- s := NewSlice[string]()
- s.Append("world")
- s.Prepend("hello")
-
- require.Equal(t, 2, s.Len())
- val, ok := s.Get(0)
- require.True(t, ok)
- require.Equal(t, "hello", val)
-
- val, ok = s.Get(1)
- require.True(t, ok)
- require.Equal(t, "world", val)
- })
-
- t.Run("Delete", func(t *testing.T) {
- s := NewSliceFrom([]int{1, 2, 3, 4, 5})
-
- // Delete middle element
- ok := s.Delete(2)
- require.True(t, ok)
- require.Equal(t, 4, s.Len())
-
- expected := []int{1, 2, 4, 5}
- actual := slices.Collect(s.Seq())
- require.Equal(t, expected, actual)
-
- // Delete out of bounds
- ok = s.Delete(10)
- require.False(t, ok)
- require.Equal(t, 4, s.Len())
-
- // Delete negative index
- ok = s.Delete(-1)
- require.False(t, ok)
- require.Equal(t, 4, s.Len())
- })
-
t.Run("Get", func(t *testing.T) {
s := NewSliceFrom([]string{"a", "b", "c"})
@@ -163,25 +125,6 @@ func TestSlice(t *testing.T) {
require.False(t, ok)
})
- t.Run("Set", func(t *testing.T) {
- s := NewSliceFrom([]string{"a", "b", "c"})
-
- ok := s.Set(1, "modified")
- require.True(t, ok)
-
- val, ok := s.Get(1)
- require.True(t, ok)
- require.Equal(t, "modified", val)
-
- // Out of bounds
- ok = s.Set(10, "invalid")
- require.False(t, ok)
-
- // Negative index
- ok = s.Set(-1, "invalid")
- require.False(t, ok)
- })
-
t.Run("SetSlice", func(t *testing.T) {
s := NewSlice[int]()
s.Append(1)
@@ -0,0 +1,44 @@
+package csync
+
+import (
+ "reflect"
+ "sync"
+)
+
+// Value is a generic thread-safe wrapper for any value type.
+//
+// For slices, use [Slice]. For maps, use [Map]. Pointers are not supported.
+type Value[T any] struct {
+ v T
+ mu sync.RWMutex
+}
+
+// NewValue creates a new Value with the given initial value.
+//
+// Panics if t is a pointer, slice, or map. Use the dedicated types for those.
+func NewValue[T any](t T) *Value[T] {
+ v := reflect.ValueOf(t)
+ switch v.Kind() {
+ case reflect.Pointer:
+ panic("csync.Value does not support pointer types")
+ case reflect.Slice:
+ panic("csync.Value does not support slice types; use csync.Slice")
+ case reflect.Map:
+ panic("csync.Value does not support map types; use csync.Map")
+ }
+ return &Value[T]{v: t}
+}
+
+// Get returns the current value.
+func (v *Value[T]) Get() T {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+ return v.v
+}
+
+// Set updates the value.
+func (v *Value[T]) Set(t T) {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ v.v = t
+}
@@ -0,0 +1,99 @@
+package csync
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestValue_GetSet(t *testing.T) {
+ t.Parallel()
+
+ v := NewValue(42)
+ require.Equal(t, 42, v.Get())
+
+ v.Set(100)
+ require.Equal(t, 100, v.Get())
+}
+
+func TestValue_ZeroValue(t *testing.T) {
+ t.Parallel()
+
+ v := NewValue("")
+ require.Equal(t, "", v.Get())
+
+ v.Set("hello")
+ require.Equal(t, "hello", v.Get())
+}
+
+func TestValue_Struct(t *testing.T) {
+ t.Parallel()
+
+ type config struct {
+ Name string
+ Count int
+ }
+
+ v := NewValue(config{Name: "test", Count: 1})
+ require.Equal(t, config{Name: "test", Count: 1}, v.Get())
+
+ v.Set(config{Name: "updated", Count: 2})
+ require.Equal(t, config{Name: "updated", Count: 2}, v.Get())
+}
+
+func TestValue_PointerPanics(t *testing.T) {
+ t.Parallel()
+
+ require.Panics(t, func() {
+ NewValue(&struct{}{})
+ })
+}
+
+func TestValue_SlicePanics(t *testing.T) {
+ t.Parallel()
+
+ require.Panics(t, func() {
+ NewValue([]string{"a", "b"})
+ })
+}
+
+func TestValue_MapPanics(t *testing.T) {
+ t.Parallel()
+
+ require.Panics(t, func() {
+ NewValue(map[string]int{"a": 1})
+ })
+}
+
+func TestValue_ConcurrentAccess(t *testing.T) {
+ t.Parallel()
+
+ v := NewValue(0)
+ var wg sync.WaitGroup
+
+ // Concurrent writers.
+ for i := range 100 {
+ wg.Add(1)
+ go func(val int) {
+ defer wg.Done()
+ v.Set(val)
+ }(i)
+ }
+
+ // Concurrent readers.
+ for range 100 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _ = v.Get()
+ }()
+ }
+
+ wg.Wait()
+
+ // Value should be one of the set values (0-99).
+ got := v.Get()
+ require.GreaterOrEqual(t, got, 0)
+ require.Less(t, got, 100)
+}
@@ -40,6 +40,11 @@ func (m *VersionedMap[K, V]) Seq2() iter.Seq2[K, V] {
return m.m.Seq2()
}
+// Copy returns a copy of the inner map.
+func (m *VersionedMap[K, V]) Copy() map[K]V {
+ return m.m.Copy()
+}
+
// Len returns the number of items in the map.
func (m *VersionedMap[K, V]) Len() int {
return m.m.Len()
@@ -7,42 +7,21 @@ import (
"log/slog"
"path/filepath"
- "github.com/ncruces/go-sqlite3"
- "github.com/ncruces/go-sqlite3/driver"
- _ "github.com/ncruces/go-sqlite3/embed"
-
"github.com/pressly/goose/v3"
)
+// Connect opens a SQLite database connection and runs migrations.
func Connect(ctx context.Context, dataDir string) (*sql.DB, error) {
if dataDir == "" {
return nil, fmt.Errorf("data.dir is not set")
}
dbPath := filepath.Join(dataDir, "crush.db")
- // Set pragmas for better performance
- pragmas := []string{
- "PRAGMA foreign_keys = ON;",
- "PRAGMA journal_mode = WAL;",
- "PRAGMA page_size = 4096;",
- "PRAGMA cache_size = -8000;",
- "PRAGMA synchronous = NORMAL;",
- "PRAGMA secure_delete = ON;",
- }
-
- db, err := driver.Open(dbPath, func(c *sqlite3.Conn) error {
- for _, pragma := range pragmas {
- if err := c.Exec(pragma); err != nil {
- return fmt.Errorf("failed to set pragma `%s`: %w", pragma, err)
- }
- }
- return nil
- })
+ db, err := openDB(dbPath)
if err != nil {
- return nil, fmt.Errorf("failed to open database: %w", err)
+ return nil, err
}
- // Verify connection
if err = db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("failed to connect to database: %w", err)
@@ -0,0 +1,31 @@
+//go:build (darwin && (amd64 || arm64)) || (freebsd && (amd64 || arm64)) || (linux && (386 || amd64 || arm || arm64 || loong64 || ppc64le || riscv64 || s390x)) || (windows && (386 || amd64 || arm64))
+
+package db
+
+import (
+ "database/sql"
+ "fmt"
+ "net/url"
+
+ _ "modernc.org/sqlite"
+)
+
+func openDB(dbPath string) (*sql.DB, error) {
+ // Set pragmas for better performance via _pragma query params.
+ // Format: _pragma=name(value)
+ params := url.Values{}
+ params.Add("_pragma", "foreign_keys(on)")
+ params.Add("_pragma", "journal_mode(WAL)")
+ params.Add("_pragma", "page_size(4096)")
+ params.Add("_pragma", "cache_size(-8000)")
+ params.Add("_pragma", "synchronous(NORMAL)")
+ params.Add("_pragma", "secure_delete(on)")
+ params.Add("_pragma", "busy_timeout(5000)")
+
+ dsn := fmt.Sprintf("file:%s?%s", dbPath, params.Encode())
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open database: %w", err)
+ }
+ return db, nil
+}
@@ -0,0 +1,38 @@
+//go:build !((darwin && (amd64 || arm64)) || (freebsd && (amd64 || arm64)) || (linux && (386 || amd64 || arm || arm64 || loong64 || ppc64le || riscv64 || s390x)) || (windows && (386 || amd64 || arm64)))
+
+package db
+
+import (
+ "database/sql"
+ "fmt"
+
+ "github.com/ncruces/go-sqlite3"
+ "github.com/ncruces/go-sqlite3/driver"
+ _ "github.com/ncruces/go-sqlite3/embed"
+)
+
+func openDB(dbPath string) (*sql.DB, error) {
+ // Set pragmas for better performance.
+ pragmas := []string{
+ "PRAGMA foreign_keys = ON;",
+ "PRAGMA journal_mode = WAL;",
+ "PRAGMA page_size = 4096;",
+ "PRAGMA cache_size = -8000;",
+ "PRAGMA synchronous = NORMAL;",
+ "PRAGMA secure_delete = ON;",
+ "PRAGMA busy_timeout = 5000;",
+ }
+
+ db, err := driver.Open(dbPath, func(c *sqlite3.Conn) error {
+ for _, pragma := range pragmas {
+ if err := c.Exec(pragma); err != nil {
+ return fmt.Errorf("failed to set pragma %q: %w", pragma, err)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to open database: %w", err)
+ }
+ return db, nil
+}
@@ -7,6 +7,7 @@ import (
"path/filepath"
"reflect"
"runtime"
+ "time"
"github.com/charmbracelet/crush/internal/version"
"github.com/posthog/posthog-go"
@@ -85,19 +86,16 @@ func Error(err any, props ...any) {
if client == nil {
return
}
- // The PostHog Go client does not yet support sending exceptions.
- // We're mimicking the behavior by sending the minimal info required
- // for PostHog to recognize this as an exception event.
- props = append(
- []any{
- "$exception_list",
- []map[string]string{
- {"type": reflect.TypeOf(err).String(), "value": fmt.Sprintf("%v", err)},
- },
- },
- props...,
- )
- send("$exception", props...)
+ posthogErr := client.Enqueue(posthog.NewDefaultException(
+ time.Now(),
+ distinctId,
+ reflect.TypeOf(err).String(),
+ fmt.Sprintf("%v", err),
+ ))
+ if err != nil {
+ slog.Error("Failed to enqueue PostHog error", "err", err, "props", props, "posthogErr", posthogErr)
+ return
+ }
}
func Flush() {
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync"
"sync/atomic"
"time"
@@ -21,6 +22,14 @@ import (
"github.com/charmbracelet/x/powernap/pkg/transport"
)
+// DiagnosticCounts holds the count of diagnostics by severity.
+type DiagnosticCounts struct {
+ Error int
+ Warning int
+ Information int
+ Hint int
+}
+
type Client struct {
client *powernap.Client
name string
@@ -37,6 +46,11 @@ type Client struct {
// Diagnostic cache
diagnostics *csync.VersionedMap[protocol.DocumentURI, []protocol.Diagnostic]
+ // Cached diagnostic counts to avoid map copy on every UI render.
+ diagCountsCache DiagnosticCounts
+ diagCountsVersion uint64
+ diagCountsMu sync.Mutex
+
// Files are currently opened by the LSP
openFiles *csync.Map[string, *OpenFileInfo]
@@ -139,10 +153,6 @@ func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol
// Close closes the LSP client.
func (c *Client) Close(ctx context.Context) error {
- // Try to close all open files first
- ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
- defer cancel()
-
c.CloseAllFiles(ctx)
// Shutdown and exit the client
@@ -350,7 +360,41 @@ func (c *Client) GetFileDiagnostics(uri protocol.DocumentURI) []protocol.Diagnos
// GetDiagnostics returns all diagnostics for all files.
func (c *Client) GetDiagnostics() map[protocol.DocumentURI][]protocol.Diagnostic {
- return maps.Collect(c.diagnostics.Seq2())
+ return c.diagnostics.Copy()
+}
+
+// GetDiagnosticCounts returns cached diagnostic counts by severity.
+// Uses the VersionedMap version to avoid recomputing on every call.
+func (c *Client) GetDiagnosticCounts() DiagnosticCounts {
+ currentVersion := c.diagnostics.Version()
+
+ c.diagCountsMu.Lock()
+ defer c.diagCountsMu.Unlock()
+
+ if currentVersion == c.diagCountsVersion {
+ return c.diagCountsCache
+ }
+
+ // Recompute counts.
+ counts := DiagnosticCounts{}
+ for _, diags := range c.diagnostics.Seq2() {
+ for _, diag := range diags {
+ switch diag.Severity {
+ case protocol.SeverityError:
+ counts.Error++
+ case protocol.SeverityWarning:
+ counts.Warning++
+ case protocol.SeverityInformation:
+ counts.Information++
+ case protocol.SeverityHint:
+ counts.Hint++
+ }
+ }
+ }
+
+ c.diagCountsCache = counts
+ c.diagCountsVersion = currentVersion
+ return counts
}
// OpenFileOnDemand opens a file only if it's not already open.
@@ -1,6 +1,9 @@
package message
-import "strings"
+import (
+ "slices"
+ "strings"
+)
type Attachment struct {
FilePath string
@@ -11,3 +14,10 @@ type Attachment struct {
func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
+
+// ContainsTextAttachment returns true if any of the attachments is a text attachments.
+func ContainsTextAttachment(attachments []Attachment) bool {
+ return slices.ContainsFunc(attachments, func(a Attachment) bool {
+ return a.IsText()
+ })
+}
@@ -437,23 +437,27 @@ func (m *Message) AddBinary(mimeType string, data []byte) {
}
func PromptWithTextAttachments(prompt string, attachments []Attachment) string {
+ var sb strings.Builder
+ sb.WriteString(prompt)
addedAttachments := false
for _, content := range attachments {
if !content.IsText() {
continue
}
if !addedAttachments {
- prompt += "\n<system_info>The files below have been attached by the user, consider them in your response</system_info>\n"
+ sb.WriteString("\n<system_info>The files below have been attached by the user, consider them in your response</system_info>\n")
addedAttachments = true
}
- tag := `<file>\n`
if content.FilePath != "" {
- tag = fmt.Sprintf("<file path='%s'>\n", content.FilePath)
+ fmt.Fprintf(&sb, "<file path='%s'>\n", content.FilePath)
+ } else {
+ sb.WriteString("<file>\n")
}
- prompt += tag
- prompt += "\n" + string(content.Content) + "\n</file>\n"
+ sb.WriteString("\n")
+ sb.Write(content.Content)
+ sb.WriteString("\n</file>\n")
}
- return prompt
+ return sb.String()
}
func (m *Message) ToAIMessage() []fantasy.Message {
@@ -0,0 +1,45 @@
+package message
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func makeTestAttachments(n int, contentSize int) []Attachment {
+ attachments := make([]Attachment, n)
+ content := []byte(strings.Repeat("x", contentSize))
+ for i := range n {
+ attachments[i] = Attachment{
+ FilePath: fmt.Sprintf("/path/to/file%d.txt", i),
+ MimeType: "text/plain",
+ Content: content,
+ }
+ }
+ return attachments
+}
+
+func BenchmarkPromptWithTextAttachments(b *testing.B) {
+ cases := []struct {
+ name string
+ numFiles int
+ contentSize int
+ }{
+ {"1file_100bytes", 1, 100},
+ {"5files_1KB", 5, 1024},
+ {"10files_10KB", 10, 10 * 1024},
+ {"20files_50KB", 20, 50 * 1024},
+ }
+
+ for _, tc := range cases {
+ attachments := makeTestAttachments(tc.numFiles, tc.contentSize)
+ prompt := "Process these files"
+
+ b.Run(tc.name, func(b *testing.B) {
+ b.ReportAllocs()
+ for range b.N {
+ _ = PromptWithTextAttachments(prompt, attachments)
+ }
+ })
+ }
+}
@@ -1,28 +0,0 @@
-package claude
-
-import (
- "crypto/rand"
- "crypto/sha256"
- "encoding/base64"
- "strings"
-)
-
-// GetChallenge generates a PKCE verifier and its corresponding challenge.
-func GetChallenge() (verifier string, challenge string, err error) {
- bytes := make([]byte, 32)
- if _, err := rand.Read(bytes); err != nil {
- return "", "", err
- }
- verifier = encodeBase64(bytes)
- hash := sha256.Sum256([]byte(verifier))
- challenge = encodeBase64(hash[:])
- return verifier, challenge, nil
-}
-
-func encodeBase64(input []byte) (encoded string) {
- encoded = base64.StdEncoding.EncodeToString(input)
- encoded = strings.ReplaceAll(encoded, "=", "")
- encoded = strings.ReplaceAll(encoded, "+", "-")
- encoded = strings.ReplaceAll(encoded, "/", "_")
- return encoded
-}
@@ -1,126 +0,0 @@
-package claude
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
-
- "github.com/charmbracelet/crush/internal/oauth"
-)
-
-const clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
-
-// AuthorizeURL returns the Claude Code Max OAuth2 authorization URL.
-func AuthorizeURL(verifier, challenge string) (string, error) {
- u, err := url.Parse("https://claude.ai/oauth/authorize")
- if err != nil {
- return "", err
- }
- q := u.Query()
- q.Set("response_type", "code")
- q.Set("client_id", clientId)
- q.Set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
- q.Set("scope", "org:create_api_key user:profile user:inference")
- q.Set("code_challenge", challenge)
- q.Set("code_challenge_method", "S256")
- q.Set("state", verifier)
- u.RawQuery = q.Encode()
- return u.String(), nil
-}
-
-// ExchangeToken exchanges the authorization code for an OAuth2 token.
-func ExchangeToken(ctx context.Context, code, verifier string) (*oauth.Token, error) {
- code = strings.TrimSpace(code)
- parts := strings.SplitN(code, "#", 2)
- pure := parts[0]
- state := ""
- if len(parts) > 1 {
- state = parts[1]
- }
-
- reqBody := map[string]string{
- "code": pure,
- "state": state,
- "grant_type": "authorization_code",
- "client_id": clientId,
- "redirect_uri": "https://console.anthropic.com/oauth/code/callback",
- "code_verifier": verifier,
- }
-
- resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, err
- }
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("claude code max: failed to exchange token: status %d body %q", resp.StatusCode, string(body))
- }
-
- var token oauth.Token
- if err := json.Unmarshal(body, &token); err != nil {
- return nil, err
- }
- token.SetExpiresAt()
- return &token, nil
-}
-
-// RefreshToken refreshes the OAuth2 token using the provided refresh token.
-func RefreshToken(ctx context.Context, refreshToken string) (*oauth.Token, error) {
- reqBody := map[string]string{
- "grant_type": "refresh_token",
- "refresh_token": refreshToken,
- "client_id": clientId,
- }
-
- resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, err
- }
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("claude code max: failed to refresh token: status %d body %q", resp.StatusCode, string(body))
- }
-
- var token oauth.Token
- if err := json.Unmarshal(body, &token); err != nil {
- return nil, err
- }
- token.SetExpiresAt()
- return &token, nil
-}
-
-func request(ctx context.Context, method, url string, body any) (*http.Response, error) {
- date, err := json.Marshal(body)
- if err != nil {
- return nil, err
- }
-
- req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(date))
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("User-Agent", "anthropic")
-
- client := &http.Client{Timeout: 30 * time.Second}
- return client.Do(req)
-}
@@ -12,6 +12,8 @@ import (
"github.com/charmbracelet/crush/internal/log"
)
+var assistantRolePattern = regexp.MustCompile(`"role"\s*:\s*"assistant"`)
+
// NewClient creates a new HTTP client with a custom transport that adds the
// X-Initiator header based on message history in the request body.
func NewClient(isSubAgent, debug bool) *http.Client {
@@ -58,7 +60,6 @@ func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error
// Check for assistant messages using regex to handle whitespace
// variations in the JSON while avoiding full unmarshalling overhead.
initiator := userInitiator
- assistantRolePattern := regexp.MustCompile(`"role"\s*:\s*"assistant"`)
if assistantRolePattern.Match(bodyBytes) || t.isSubAgent {
slog.Debug("Setting X-Initiator header to agent (found assistant messages in history)")
initiator = agentInitiator
@@ -47,7 +47,7 @@ type Service interface {
GrantPersistent(permission PermissionRequest)
Grant(permission PermissionRequest)
Deny(permission PermissionRequest)
- Request(opts CreatePermissionRequest) bool
+ Request(ctx context.Context, opts CreatePermissionRequest) (bool, error)
AutoApproveSession(sessionID string)
SetSkipRequests(skip bool)
SkipRequests() bool
@@ -68,8 +68,9 @@ type permissionService struct {
allowedTools []string
// used to make sure we only process one request at a time
- requestMu sync.Mutex
- activeRequest *PermissionRequest
+ requestMu sync.Mutex
+ activeRequest *PermissionRequest
+ activeRequestMu sync.Mutex
}
func (s *permissionService) GrantPersistent(permission PermissionRequest) {
@@ -86,9 +87,11 @@ func (s *permissionService) GrantPersistent(permission PermissionRequest) {
s.sessionPermissions = append(s.sessionPermissions, permission)
s.sessionPermissionsMu.Unlock()
+ s.activeRequestMu.Lock()
if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
s.activeRequest = nil
}
+ s.activeRequestMu.Unlock()
}
func (s *permissionService) Grant(permission PermissionRequest) {
@@ -101,9 +104,11 @@ func (s *permissionService) Grant(permission PermissionRequest) {
respCh <- true
}
+ s.activeRequestMu.Lock()
if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
s.activeRequest = nil
}
+ s.activeRequestMu.Unlock()
}
func (s *permissionService) Deny(permission PermissionRequest) {
@@ -117,14 +122,16 @@ func (s *permissionService) Deny(permission PermissionRequest) {
respCh <- false
}
+ s.activeRequestMu.Lock()
if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
s.activeRequest = nil
}
+ s.activeRequestMu.Unlock()
}
-func (s *permissionService) Request(opts CreatePermissionRequest) bool {
+func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
if s.skip {
- return true
+ return true, nil
}
// tell the UI that a permission was requested
@@ -137,7 +144,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
// Check if the tool/action combination is in the allowlist
commandKey := opts.ToolName + ":" + opts.Action
if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
- return true
+ return true, nil
}
s.autoApproveSessionsMu.RLock()
@@ -145,7 +152,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
s.autoApproveSessionsMu.RUnlock()
if autoApprove {
- return true
+ return true, nil
}
fileInfo, err := os.Stat(opts.Path)
@@ -176,7 +183,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
for _, p := range s.sessionPermissions {
if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
s.sessionPermissionsMu.RUnlock()
- return true
+ return true, nil
}
}
s.sessionPermissionsMu.RUnlock()
@@ -185,12 +192,14 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
for _, p := range s.sessionPermissions {
if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
s.sessionPermissionsMu.RUnlock()
- return true
+ return true, nil
}
}
s.sessionPermissionsMu.RUnlock()
+ s.activeRequestMu.Lock()
s.activeRequest = &permission
+ s.activeRequestMu.Unlock()
respCh := make(chan bool, 1)
s.pendingRequests.Set(permission.ID, respCh)
@@ -199,7 +208,12 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
// Publish the request
s.Publish(pubsub.CreatedEvent, permission)
- return <-respCh
+ select {
+ case <-ctx.Done():
+ return false, ctx.Err()
+ case granted := <-respCh:
+ return granted, nil
+ }
}
func (s *permissionService) AutoApproveSession(sessionID string) {
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestPermissionService_AllowedCommands(t *testing.T) {
@@ -81,14 +82,16 @@ func TestPermissionService_AllowedCommands(t *testing.T) {
func TestPermissionService_SkipMode(t *testing.T) {
service := NewPermissionService("/tmp", true, []string{})
- result := service.Request(CreatePermissionRequest{
+ result, err := service.Request(t.Context(), CreatePermissionRequest{
SessionID: "test-session",
ToolName: "bash",
Action: "execute",
Description: "test command",
Path: "/tmp",
})
-
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
if !result {
t.Error("expected permission to be granted in skip mode")
}
@@ -115,7 +118,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
go func() {
defer wg.Done()
- result1 = service.Request(req1)
+ result1, _ = service.Request(t.Context(), req1)
}()
var permissionReq PermissionRequest
@@ -136,7 +139,8 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
Params: map[string]string{"file": "test.txt"},
Path: "/tmp/test.txt",
}
- result2 := service.Request(req2)
+ result2, err := service.Request(t.Context(), req2)
+ require.NoError(t, err)
assert.True(t, result2, "Second request should be auto-approved")
})
t.Run("Sequential requests with temporary grants", func(t *testing.T) {
@@ -156,7 +160,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
var wg sync.WaitGroup
wg.Go(func() {
- result1 = service.Request(req)
+ result1, _ = service.Request(t.Context(), req)
})
var permissionReq PermissionRequest
@@ -170,7 +174,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
var result2 bool
wg.Go(func() {
- result2 = service.Request(req)
+ result2, _ = service.Request(t.Context(), req)
})
event = <-events
@@ -185,7 +189,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
events := service.Subscribe(t.Context())
var wg sync.WaitGroup
- results := make([]bool, 0)
+ results := make([]bool, 3)
requests := []CreatePermissionRequest{
{
@@ -215,7 +219,8 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
wg.Add(1)
go func(index int, request CreatePermissionRequest) {
defer wg.Done()
- results = append(results, service.Request(request))
+ result, _ := service.Request(t.Context(), request)
+ results[index] = result
}(i, req)
}
@@ -241,7 +246,8 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
assert.Equal(t, 2, grantedCount, "Should have 2 granted and 1 denied")
secondReq := requests[1]
secondReq.Description = "Repeat of second request"
- result := service.Request(secondReq)
+ result, err := service.Request(t.Context(), secondReq)
+ require.NoError(t, err)
assert.True(t, result, "Repeated request should be auto-approved due to persistent permission")
})
}
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
+ "slices"
"sync"
"sync/atomic"
"time"
@@ -18,6 +19,30 @@ const (
CompletedJobRetentionMinutes = 8 * 60
)
+// syncBuffer is a thread-safe wrapper around bytes.Buffer.
+type syncBuffer struct {
+ buf bytes.Buffer
+ mu sync.RWMutex
+}
+
+func (sb *syncBuffer) Write(p []byte) (n int, err error) {
+ sb.mu.Lock()
+ defer sb.mu.Unlock()
+ return sb.buf.Write(p)
+}
+
+func (sb *syncBuffer) WriteString(s string) (n int, err error) {
+ sb.mu.Lock()
+ defer sb.mu.Unlock()
+ return sb.buf.WriteString(s)
+}
+
+func (sb *syncBuffer) String() string {
+ sb.mu.RLock()
+ defer sb.mu.RUnlock()
+ return sb.buf.String()
+}
+
// BackgroundShell represents a shell running in the background.
type BackgroundShell struct {
ID string
@@ -27,8 +52,8 @@ type BackgroundShell struct {
WorkingDir string
ctx context.Context
cancel context.CancelFunc
- stdout *bytes.Buffer
- stderr *bytes.Buffer
+ stdout *syncBuffer
+ stderr *syncBuffer
done chan struct{}
exitErr error
completedAt int64 // Unix timestamp when job completed (0 if still running)
@@ -45,12 +70,17 @@ var (
idCounter atomic.Uint64
)
+// newBackgroundShellManager creates a new BackgroundShellManager instance.
+func newBackgroundShellManager() *BackgroundShellManager {
+ return &BackgroundShellManager{
+ shells: csync.NewMap[string, *BackgroundShell](),
+ }
+}
+
// GetBackgroundShellManager returns the singleton background shell manager.
func GetBackgroundShellManager() *BackgroundShellManager {
backgroundManagerOnce.Do(func() {
- backgroundManager = &BackgroundShellManager{
- shells: csync.NewMap[string, *BackgroundShell](),
- }
+ backgroundManager = newBackgroundShellManager()
})
return backgroundManager
}
@@ -79,8 +109,8 @@ func (m *BackgroundShellManager) Start(ctx context.Context, workingDir string, b
Shell: shell,
ctx: shellCtx,
cancel: cancel,
- stdout: &bytes.Buffer{},
- stderr: &bytes.Buffer{},
+ stdout: &syncBuffer{},
+ stderr: &syncBuffer{},
done: make(chan struct{}),
}
@@ -163,15 +193,26 @@ func (m *BackgroundShellManager) Cleanup() int {
// KillAll terminates all background shells.
func (m *BackgroundShellManager) KillAll() {
- shells := make([]*BackgroundShell, 0, m.shells.Len())
- for shell := range m.shells.Seq() {
- shells = append(shells, shell)
- }
+ shells := slices.Collect(m.shells.Seq())
m.shells.Reset(map[string]*BackgroundShell{})
+ done := make(chan struct{}, 1)
+ go func() {
+ var wg sync.WaitGroup
+ for _, shell := range shells {
+ wg.Go(func() {
+ shell.cancel()
+ <-shell.done
+ })
+ }
+ wg.Wait()
+ done <- struct{}{}
+ }()
- for _, shell := range shells {
- shell.cancel()
- <-shell.done
+ select {
+ case <-done:
+ return
+ case <-time.After(time.Second * 5):
+ return
}
}
@@ -14,7 +14,7 @@ func TestBackgroundShellManager_Start(t *testing.T) {
ctx := context.Background()
workingDir := t.TempDir()
- manager := GetBackgroundShellManager()
+ manager := newBackgroundShellManager()
bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'hello world'", "")
if err != nil {
@@ -51,7 +51,7 @@ func TestBackgroundShellManager_Get(t *testing.T) {
ctx := context.Background()
workingDir := t.TempDir()
- manager := GetBackgroundShellManager()
+ manager := newBackgroundShellManager()
bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'test'", "")
if err != nil {
@@ -77,7 +77,7 @@ func TestBackgroundShellManager_Kill(t *testing.T) {
ctx := context.Background()
workingDir := t.TempDir()
- manager := GetBackgroundShellManager()
+ manager := newBackgroundShellManager()
// Start a long-running command
bgShell, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
@@ -106,7 +106,7 @@ func TestBackgroundShellManager_Kill(t *testing.T) {
func TestBackgroundShellManager_KillNonExistent(t *testing.T) {
t.Parallel()
- manager := GetBackgroundShellManager()
+ manager := newBackgroundShellManager()
err := manager.Kill("non-existent-id")
if err == nil {
@@ -119,7 +119,7 @@ func TestBackgroundShell_IsDone(t *testing.T) {
ctx := context.Background()
workingDir := t.TempDir()
- manager := GetBackgroundShellManager()
+ manager := newBackgroundShellManager()
bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'quick'", "")
if err != nil {
@@ -142,7 +142,7 @@ func TestBackgroundShell_WithBlockFuncs(t *testing.T) {
ctx := context.Background()
workingDir := t.TempDir()
- manager := GetBackgroundShellManager()
+ manager := newBackgroundShellManager()
blockFuncs := []BlockFunc{
CommandsBlocker([]string{"curl", "wget"}),
@@ -180,7 +180,7 @@ func TestBackgroundShellManager_List(t *testing.T) {
ctx := context.Background()
workingDir := t.TempDir()
- manager := GetBackgroundShellManager()
+ manager := newBackgroundShellManager()
// Start two shells
bgShell1, err := manager.Start(ctx, workingDir, nil, "sleep 1", "")
@@ -224,7 +224,7 @@ func TestBackgroundShellManager_KillAll(t *testing.T) {
ctx := context.Background()
workingDir := t.TempDir()
- manager := GetBackgroundShellManager()
+ manager := newBackgroundShellManager()
// Start multiple long-running shells
shell1, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
@@ -247,12 +247,14 @@ func (s *Shell) newInterp(stdout, stderr io.Writer) (*interp.Runner, error) {
)
}
-// updateShellFromRunner updates the shell from the interpreter after execution
+// updateShellFromRunner updates the shell from the interpreter after execution.
func (s *Shell) updateShellFromRunner(runner *interp.Runner) {
s.cwd = runner.Dir
- s.env = nil
+ s.env = s.env[:0]
for name, vr := range runner.Vars {
- s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
+ if vr.Exported {
+ s.env = append(s.env, name+"="+vr.Str)
+ }
}
}
@@ -147,7 +147,7 @@ func Discover(paths []string) []*Skill {
slog.Warn("Skill validation failed", "path", path, "error", err)
return nil
}
- slog.Info("Successfully loaded skill", "name", skill.Name, "path", path)
+ slog.Debug("Successfully loaded skill", "name", skill.Name, "path", path)
mu.Lock()
skills = append(skills, skill)
mu.Unlock()
@@ -0,0 +1,8 @@
+package editor
+
+type clipboardFormat int
+
+const (
+ clipboardFormatText clipboardFormat = iota
+ clipboardFormatImage
+)
@@ -0,0 +1,7 @@
+//go:build !(darwin || linux || windows) || arm || 386 || ios || android
+
+package editor
+
+func readClipboard(clipboardFormat) ([]byte, error) {
+ return nil, errClipboardPlatformUnsupported
+}
@@ -0,0 +1,15 @@
+//go:build (linux || darwin || windows) && !arm && !386 && !ios && !android
+
+package editor
+
+import "github.com/aymanbagabas/go-nativeclipboard"
+
+func readClipboard(f clipboardFormat) ([]byte, error) {
+ switch f {
+ case clipboardFormatText:
+ return nativeclipboard.Text.Read()
+ case clipboardFormatImage:
+ return nativeclipboard.Image.Read()
+ }
+ return nil, errClipboardUnknownFormat
+}
@@ -1,15 +1,14 @@
package editor
import (
- "context"
- "errors"
"fmt"
"math/rand"
"net/http"
"os"
"path/filepath"
- "runtime"
+ "regexp"
"slices"
+ "strconv"
"strings"
"unicode"
@@ -32,6 +31,12 @@ import (
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/x/ansi"
+ "github.com/charmbracelet/x/editor"
+)
+
+var (
+ errClipboardPlatformUnsupported = fmt.Errorf("clipboard operations are not supported on this platform")
+ errClipboardUnknownFormat = fmt.Errorf("unknown clipboard format")
)
type Editor interface {
@@ -94,16 +99,6 @@ type OpenEditorMsg struct {
}
func (m *editorCmp) openEditor(value string) tea.Cmd {
- editor := os.Getenv("EDITOR")
- if editor == "" {
- // Use platform-appropriate default editor
- if runtime.GOOS == "windows" {
- editor = "notepad"
- } else {
- editor = "nvim"
- }
- }
-
tmpfile, err := os.CreateTemp("", "msg_*.md")
if err != nil {
return util.ReportError(err)
@@ -112,8 +107,18 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
if _, err := tmpfile.WriteString(value); err != nil {
return util.ReportError(err)
}
- cmdStr := editor + " " + tmpfile.Name()
- return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
+ cmd, err := editor.Command(
+ "crush",
+ tmpfile.Name(),
+ editor.AtPosition(
+ m.textarea.Line()+1,
+ m.textarea.Column()+1,
+ ),
+ )
+ if err != nil {
+ return util.ReportError(err)
+ }
+ return tea.ExecProcess(cmd, func(err error) tea.Msg {
if err != nil {
return util.ReportError(err)
}
@@ -147,7 +152,7 @@ func (m *editorCmp) send() tea.Cmd {
attachments := m.attachments
- if value == "" {
+ if value == "" && !message.ContainsTextAttachment(attachments) {
return nil
}
@@ -234,13 +239,31 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
m.textarea.SetValue(msg.Text)
m.textarea.MoveToEnd()
case tea.PasteMsg:
- content, path, err := pasteToFile(msg)
- if errors.Is(err, errNotAFile) {
- m.textarea, cmd = m.textarea.Update(msg)
- return m, cmd
+ // If pasted text has more than 2 newlines, treat it as a file attachment.
+ if strings.Count(msg.Content, "\n") > 2 {
+ content := []byte(msg.Content)
+ if len(content) > maxAttachmentSize {
+ return m, util.ReportWarn("Paste is too big (>5mb)")
+ }
+ name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
+ mimeType := mimeOf(content)
+ attachment := message.Attachment{
+ FileName: name,
+ FilePath: name,
+ MimeType: mimeType,
+ Content: content,
+ }
+ return m, util.CmdHandler(filepicker.FilePickedMsg{
+ Attachment: attachment,
+ })
}
+
+ // Try to parse as a file path.
+ content, path, err := filepathToFile(msg.Content)
if err != nil {
- return m, util.ReportError(err)
+ // Not a file path, just update the textarea normally.
+ m.textarea, cmd = m.textarea.Update(msg)
+ return m, cmd
}
if len(content) > maxAttachmentSize {
@@ -257,7 +280,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
if !attachment.IsText() && !attachment.IsImage() {
return m, util.ReportWarn("Invalid file content type: " + mimeType)
}
- m.textarea.InsertString(attachment.FileName)
return m, util.CmdHandler(filepicker.FilePickedMsg{
Attachment: attachment,
})
@@ -321,6 +343,84 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
m.textarea.InsertRune('\n')
cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
}
+ // Handle image paste from clipboard
+ if key.Matches(msg, m.keyMap.PasteImage) {
+ imageData, err := readClipboard(clipboardFormatImage)
+
+ if err != nil || len(imageData) == 0 {
+ // If no image data found, try to get text data (could be file path)
+ var textData []byte
+ textData, err = readClipboard(clipboardFormatText)
+ if err != nil || len(textData) == 0 {
+ // If clipboard is empty, show a warning
+ return m, util.ReportWarn("No data found in clipboard. Note: Some terminals may not support reading image data from clipboard directly.")
+ }
+
+ // Check if the text data is a file path
+ textStr := string(textData)
+ // First, try to interpret as a file path (existing functionality)
+ path := strings.ReplaceAll(textStr, "\\ ", " ")
+ path, err = filepath.Abs(strings.TrimSpace(path))
+ if err == nil {
+ isAllowedType := false
+ for _, ext := range filepicker.AllowedTypes {
+ if strings.HasSuffix(path, ext) {
+ isAllowedType = true
+ break
+ }
+ }
+ if isAllowedType {
+ tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
+ if !tooBig {
+ content, err := os.ReadFile(path)
+ if err == nil {
+ mimeBufferSize := min(512, len(content))
+ mimeType := http.DetectContentType(content[:mimeBufferSize])
+ fileName := filepath.Base(path)
+ attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
+ return m, util.CmdHandler(filepicker.FilePickedMsg{
+ Attachment: attachment,
+ })
+ }
+ }
+ }
+ }
+
+ // If not a valid file path, show a warning
+ return m, util.ReportWarn("No image found in clipboard")
+ } else {
+ // We have image data from the clipboard
+ // Create a temporary file to store the clipboard image data
+ tempFile, err := os.CreateTemp("", "clipboard_image_crush_*")
+ if err != nil {
+ return m, util.ReportError(err)
+ }
+ defer tempFile.Close()
+
+ // Write clipboard content to the temporary file
+ _, err = tempFile.Write(imageData)
+ if err != nil {
+ return m, util.ReportError(err)
+ }
+
+ // Determine the file extension based on the image data
+ mimeBufferSize := min(512, len(imageData))
+ mimeType := http.DetectContentType(imageData[:mimeBufferSize])
+
+ // Create an attachment from the temporary file
+ fileName := filepath.Base(tempFile.Name())
+ attachment := message.Attachment{
+ FilePath: tempFile.Name(),
+ FileName: fileName,
+ MimeType: mimeType,
+ Content: imageData,
+ }
+
+ return m, util.CmdHandler(filepicker.FilePickedMsg{
+ Attachment: attachment,
+ })
+ }
+ }
// Handle Enter key
if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
value := m.textarea.Value()
@@ -628,33 +728,21 @@ func New(app *app.App) Editor {
var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
-var errNotAFile = errors.New("not a file")
-
-func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) {
- content, path, err := filepathToFile(msg.Content)
- if err == nil {
- return content, path, err
- }
-
- if strings.Count(msg.Content, "\n") > 2 {
- return contentToFile([]byte(msg.Content))
- }
-
- return nil, "", errNotAFile
-}
+var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
-func contentToFile(content []byte) ([]byte, string, error) {
- f, err := os.CreateTemp("", "paste_*.txt")
- if err != nil {
- return nil, "", err
- }
- if _, err := f.Write(content); err != nil {
- return nil, "", err
- }
- if err := f.Close(); err != nil {
- return nil, "", err
+func (m *editorCmp) pasteIdx() int {
+ result := 0
+ for _, at := range m.attachments {
+ found := pasteRE.FindStringSubmatch(at.FileName)
+ if len(found) == 0 {
+ continue
+ }
+ idx, err := strconv.Atoi(found[1])
+ if err == nil {
+ result = max(result, idx)
+ }
}
- return content, f.Name(), nil
+ return result + 1
}
func filepathToFile(name string) ([]byte, string, error) {
@@ -9,6 +9,7 @@ type EditorKeyMap struct {
SendMessage key.Binding
OpenEditor key.Binding
Newline key.Binding
+ PasteImage key.Binding
}
func DefaultEditorKeyMap() EditorKeyMap {
@@ -32,6 +33,10 @@ func DefaultEditorKeyMap() EditorKeyMap {
// to reflect that.
key.WithHelp("ctrl+j", "newline"),
),
+ PasteImage: key.NewBinding(
+ key.WithKeys("ctrl+v"),
+ key.WithHelp("ctrl+v", "paste image from clipboard"),
+ ),
}
}
@@ -42,6 +47,7 @@ func (k EditorKeyMap) KeyBindings() []key.Binding {
k.SendMessage,
k.OpenEditor,
k.Newline,
+ k.PasteImage,
AttachmentsKeyMaps.AttachmentDeleteMode,
AttachmentsKeyMaps.DeleteAllAttachments,
AttachmentsKeyMaps.Escape,
@@ -15,7 +15,6 @@ import (
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
)
type Header interface {
@@ -106,13 +105,7 @@ func (h *header) details(availWidth int) string {
errorCount := 0
for l := range h.lspClients.Seq() {
- for _, diagnostics := range l.GetDiagnostics() {
- for _, diagnostic := range diagnostics {
- if diagnostic.Severity == protocol.SeverityError {
- errorCount++
- }
- }
- }
+ errorCount += l.GetDiagnosticCounts().Error
}
if errorCount > 0 {
@@ -223,8 +223,10 @@ func (m *messageCmp) renderAssistantMessage() string {
// message content and any attached files with appropriate icons.
func (m *messageCmp) renderUserMessage() string {
t := styles.CurrentTheme()
- parts := []string{
- m.toMarkdown(m.message.Content().String()),
+ var parts []string
+
+ if s := m.message.Content().String(); s != "" {
+ parts = append(parts, m.toMarkdown(s))
}
attachmentStyle := t.S().Base.
@@ -256,7 +258,7 @@ func (m *messageCmp) renderUserMessage() string {
}
if len(attachments) > 0 {
- parts = append(parts, "", strings.Join(attachments, ""))
+ parts = append(parts, strings.Join(attachments, ""))
}
joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
@@ -9,7 +9,6 @@ import (
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
- "github.com/atotto/clipboard"
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent"
hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
@@ -18,7 +17,6 @@ import (
"github.com/charmbracelet/crush/internal/tui/components/chat"
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
@@ -47,18 +45,6 @@ type Splash interface {
// IsAPIKeyValid returns whether the API key is valid
IsAPIKeyValid() bool
- // IsShowingClaudeAuthMethodChooser returns whether showing Claude auth method chooser
- IsShowingClaudeAuthMethodChooser() bool
-
- // IsShowingClaudeOAuth2 returns whether showing Claude OAuth2 flow
- IsShowingClaudeOAuth2() bool
-
- // IsClaudeOAuthURLState returns whether in OAuth URL state
- IsClaudeOAuthURLState() bool
-
- // IsClaudeOAuthComplete returns whether Claude OAuth flow is complete
- IsClaudeOAuthComplete() bool
-
// IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow
IsShowingHyperOAuth2() bool
@@ -103,12 +89,6 @@ type splashCmp struct {
// Copilot device flow state
copilotDeviceFlow *copilot.DeviceFlow
showCopilotDeviceFlow bool
-
- // Claude state
- claudeAuthMethodChooser *claude.AuthMethodChooser
- claudeOAuth2 *claude.OAuth2
- showClaudeAuthMethodChooser bool
- showClaudeOAuth2 bool
}
func New() Splash {
@@ -134,9 +114,6 @@ func New() Splash {
modelList: modelList,
apiKeyInput: apiKeyInput,
selectedNo: false,
-
- claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
- claudeOAuth2: claude.NewOAuth2(),
}
}
@@ -158,8 +135,6 @@ func (s *splashCmp) Init() tea.Cmd {
return tea.Batch(
s.modelList.Init(),
s.apiKeyInput.Init(),
- s.claudeAuthMethodChooser.Init(),
- s.claudeOAuth2.Init(),
)
}
@@ -176,7 +151,6 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
listWidth := min(60, width)
s.apiKeyInput.SetWidth(width - 2)
- s.claudeAuthMethodChooser.SetWidth(min(width-2, 60))
return s.modelList.SetSize(listWidth, s.listHeight)
}
@@ -185,24 +159,6 @@ 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)
- case claude.ValidationCompletedMsg:
- var cmds []tea.Cmd
- u, cmd := s.claudeOAuth2.Update(msg)
- s.claudeOAuth2 = u.(*claude.OAuth2)
- cmds = append(cmds, cmd)
-
- if msg.State == claude.OAuthValidationStateValid {
- cmds = append(
- cmds,
- s.saveAPIKeyAndContinue(msg.Token, false),
- func() tea.Msg {
- time.Sleep(5 * time.Second)
- return claude.AuthenticationCompleteMsg{}
- },
- )
- }
-
- return s, tea.Batch(cmds...)
case hyper.DeviceFlowCompletedMsg:
s.showHyperDeviceFlow = false
return s, s.saveAPIKeyAndContinue(msg.Token, true)
@@ -223,10 +179,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
case copilot.DeviceFlowCompletedMsg:
s.showCopilotDeviceFlow = false
return s, s.saveAPIKeyAndContinue(msg.Token, true)
- case claude.AuthenticationCompleteMsg:
- s.showClaudeAuthMethodChooser = false
- s.showClaudeOAuth2 = false
- return s, util.CmdHandler(OnboardingCompleteMsg{})
case models.APIKeyStateChangeMsg:
u, cmd := s.apiKeyInput.Update(msg)
s.apiKeyInput = u.(*models.APIKeyInput)
@@ -246,34 +198,8 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return s, s.hyperDeviceFlow.CopyCode()
case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow:
return s, s.copilotDeviceFlow.CopyCode()
- case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL:
- return s, tea.Sequence(
- tea.SetClipboard(s.claudeOAuth2.URL),
- func() tea.Msg {
- _ = clipboard.WriteAll(s.claudeOAuth2.URL)
- return nil
- },
- util.ReportInfo("URL copied to clipboard"),
- )
- case key.Matches(msg, s.keyMap.Copy) && s.showClaudeAuthMethodChooser:
- u, cmd := s.claudeAuthMethodChooser.Update(msg)
- s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
- return s, cmd
- case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2:
- u, cmd := s.claudeOAuth2.Update(msg)
- s.claudeOAuth2 = u.(*claude.OAuth2)
- return s, cmd
case key.Matches(msg, s.keyMap.Back):
switch {
- case s.showClaudeAuthMethodChooser:
- s.claudeAuthMethodChooser.SetDefaults()
- s.showClaudeAuthMethodChooser = false
- return s, nil
- case s.showClaudeOAuth2:
- s.claudeOAuth2.SetDefaults()
- s.showClaudeOAuth2 = false
- s.showClaudeAuthMethodChooser = true
- return s, nil
case s.showHyperDeviceFlow:
s.hyperDeviceFlow = nil
s.showHyperDeviceFlow = false
@@ -285,9 +211,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
case s.isAPIKeyValid:
return s, nil
case s.needsAPIKey:
- if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic {
- s.showClaudeAuthMethodChooser = true
- }
s.needsAPIKey = false
s.selectedModel = nil
s.isAPIKeyValid = false
@@ -297,28 +220,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}
case key.Matches(msg, s.keyMap.Select):
switch {
- case s.showClaudeAuthMethodChooser:
- selectedItem := s.modelList.SelectedModel()
- if selectedItem == nil {
- return s, nil
- }
-
- switch s.claudeAuthMethodChooser.State {
- case claude.AuthMethodAPIKey:
- s.showClaudeAuthMethodChooser = false
- s.needsAPIKey = true
- s.selectedModel = selectedItem
- s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
- case claude.AuthMethodOAuth2:
- s.selectedModel = selectedItem
- s.showClaudeAuthMethodChooser = false
- s.showClaudeOAuth2 = true
- }
- return s, nil
- case s.showClaudeOAuth2:
- m2, cmd2 := s.claudeOAuth2.ValidationConfirm()
- s.claudeOAuth2 = m2.(*claude.OAuth2)
- return s, cmd2
case s.showHyperDeviceFlow:
return s, s.hyperDeviceFlow.CopyCodeAndOpenURL()
case s.showCopilotDeviceFlow:
@@ -336,9 +237,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
} else {
switch selectedItem.Provider.ID {
- case catwalk.InferenceProviderAnthropic:
- s.showClaudeAuthMethodChooser = true
- return s, nil
case hyperp.Name:
s.selectedModel = selectedItem
s.showHyperDeviceFlow = true
@@ -407,10 +305,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return s, s.initializeProject()
}
case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
- if s.showClaudeAuthMethodChooser {
- s.claudeAuthMethodChooser.ToggleChoice()
- return s, nil
- }
if s.needsAPIKey {
u, cmd := s.apiKeyInput.Update(msg)
s.apiKeyInput = u.(*models.APIKeyInput)
@@ -452,14 +346,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}
default:
switch {
- case s.showClaudeAuthMethodChooser:
- u, cmd := s.claudeAuthMethodChooser.Update(msg)
- s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
- return s, cmd
- case s.showClaudeOAuth2:
- u, cmd := s.claudeOAuth2.Update(msg)
- s.claudeOAuth2 = u.(*claude.OAuth2)
- return s, cmd
case s.showHyperDeviceFlow:
u, cmd := s.hyperDeviceFlow.Update(msg)
s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
@@ -480,10 +366,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}
case tea.PasteMsg:
switch {
- case s.showClaudeOAuth2:
- u, cmd := s.claudeOAuth2.Update(msg)
- s.claudeOAuth2 = u.(*claude.OAuth2)
- return s, cmd
case s.showHyperDeviceFlow:
u, cmd := s.hyperDeviceFlow.Update(msg)
s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
@@ -503,10 +385,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}
case spinner.TickMsg:
switch {
- case s.showClaudeOAuth2:
- u, cmd := s.claudeOAuth2.Update(msg)
- s.claudeOAuth2 = u.(*claude.OAuth2)
- return s, cmd
case s.showHyperDeviceFlow:
u, cmd := s.hyperDeviceFlow.Update(msg)
s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
@@ -655,38 +533,6 @@ func (s *splashCmp) View() string {
var content string
switch {
- case s.showClaudeAuthMethodChooser:
- remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
- chooserView := s.claudeAuthMethodChooser.View()
- authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
- "",
- chooserView,
- ),
- )
- content = lipgloss.JoinVertical(
- lipgloss.Left,
- s.logoRendered,
- authMethodSelector,
- )
- case s.showClaudeOAuth2:
- remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
- oauth2View := s.claudeOAuth2.View()
- oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
- "",
- oauth2View,
- ),
- )
- content = lipgloss.JoinVertical(
- lipgloss.Left,
- s.logoRendered,
- oauthSelector,
- )
case s.showHyperDeviceFlow:
remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
hyperView := s.hyperDeviceFlow.View()
@@ -816,14 +662,6 @@ func (s *splashCmp) View() string {
func (s *splashCmp) Cursor() *tea.Cursor {
switch {
- case s.showClaudeAuthMethodChooser:
- return nil
- case s.showClaudeOAuth2:
- if cursor := s.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
- cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
- return s.moveCursor(cursor)
- }
- return nil
case s.needsAPIKey:
cursor := s.apiKeyInput.Cursor()
if cursor != nil {
@@ -894,16 +732,10 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
}
// Calculate the correct Y offset based on current state
logoHeight := lipgloss.Height(s.logoRendered)
- if s.needsAPIKey || s.showClaudeOAuth2 {
- var view string
- if s.needsAPIKey {
- view = s.apiKeyInput.View()
- } else {
- view = s.claudeOAuth2.View()
- }
+ if s.needsAPIKey {
infoSectionHeight := lipgloss.Height(s.infoSection())
baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
- remainingHeight := s.height - baseOffset - lipgloss.Height(view) - SplashScreenPaddingY
+ remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
offset := baseOffset + remainingHeight
cursor.Y += offset
cursor.X += 1
@@ -926,20 +758,6 @@ func (s *splashCmp) logoGap() int {
// Bindings implements SplashPage.
func (s *splashCmp) Bindings() []key.Binding {
switch {
- case s.showClaudeAuthMethodChooser:
- return []key.Binding{
- s.keyMap.Select,
- s.keyMap.Tab,
- s.keyMap.Back,
- }
- case s.showClaudeOAuth2:
- bindings := []key.Binding{
- s.keyMap.Select,
- }
- if s.claudeOAuth2.State == claude.OAuthStateURL {
- bindings = append(bindings, s.keyMap.Copy)
- }
- return bindings
case s.needsAPIKey:
return []key.Binding{
s.keyMap.Select,
@@ -1047,22 +865,6 @@ func (s *splashCmp) IsAPIKeyValid() bool {
return s.isAPIKeyValid
}
-func (s *splashCmp) IsShowingClaudeAuthMethodChooser() bool {
- return s.showClaudeAuthMethodChooser
-}
-
-func (s *splashCmp) IsShowingClaudeOAuth2() bool {
- return s.showClaudeOAuth2
-}
-
-func (s *splashCmp) IsClaudeOAuthURLState() bool {
- return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL
-}
-
-func (s *splashCmp) IsClaudeOAuthComplete() bool {
- return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateCode && s.claudeOAuth2.ValidationState == claude.OAuthValidationStateValid
-}
-
func (s *splashCmp) IsShowingHyperOAuth2() bool {
return s.showHyperDeviceFlow
}
@@ -1,115 +0,0 @@
-package claude
-
-import (
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type AuthMethod int
-
-const (
- AuthMethodAPIKey AuthMethod = iota
- AuthMethodOAuth2
-)
-
-type AuthMethodChooser struct {
- State AuthMethod
- width int
- isOnboarding bool
-}
-
-func NewAuthMethodChooser() *AuthMethodChooser {
- return &AuthMethodChooser{
- State: AuthMethodOAuth2,
- }
-}
-
-func (a *AuthMethodChooser) Init() tea.Cmd {
- return nil
-}
-
-func (a *AuthMethodChooser) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- return a, nil
-}
-
-func (a *AuthMethodChooser) View() string {
- t := styles.CurrentTheme()
-
- white := lipgloss.NewStyle().Foreground(t.White)
- primary := lipgloss.NewStyle().Foreground(t.Primary)
- success := lipgloss.NewStyle().Foreground(t.Success)
-
- titleStyle := white
- if a.isOnboarding {
- titleStyle = primary
- }
-
- question := lipgloss.
- NewStyle().
- Margin(0, 1).
- Render(titleStyle.Render("How would you like to authenticate with ") + success.Render("Anthropic") + titleStyle.Render("?"))
-
- squareWidth := (a.width - 2) / 2
- squareHeight := squareWidth / 3
- if isOdd(squareHeight) {
- squareHeight++
- }
-
- square := lipgloss.NewStyle().
- Width(squareWidth).
- Height(squareHeight).
- Margin(0, 0).
- Border(lipgloss.RoundedBorder())
-
- squareText := lipgloss.NewStyle().
- Width(squareWidth - 2).
- Height(squareHeight).
- Align(lipgloss.Center).
- AlignVertical(lipgloss.Center)
-
- oauthBorder := t.AuthBorderSelected
- oauthText := t.AuthTextSelected
- apiKeyBorder := t.AuthBorderUnselected
- apiKeyText := t.AuthTextUnselected
-
- if a.State == AuthMethodAPIKey {
- oauthBorder, apiKeyBorder = apiKeyBorder, oauthBorder
- oauthText, apiKeyText = apiKeyText, oauthText
- }
-
- return lipgloss.JoinVertical(
- lipgloss.Left,
- question,
- "",
- lipgloss.JoinHorizontal(
- lipgloss.Center,
- square.MarginLeft(1).
- Inherit(oauthBorder).Render(squareText.Inherit(oauthText).Render("Claude Account\nwith Subscription")),
- square.MarginRight(1).
- Inherit(apiKeyBorder).Render(squareText.Inherit(apiKeyText).Render("API Key")),
- ),
- )
-}
-
-func (a *AuthMethodChooser) SetDefaults() {
- a.State = AuthMethodOAuth2
-}
-
-func (a *AuthMethodChooser) SetWidth(w int) {
- a.width = w
-}
-
-func (a *AuthMethodChooser) ToggleChoice() {
- switch a.State {
- case AuthMethodAPIKey:
- a.State = AuthMethodOAuth2
- case AuthMethodOAuth2:
- a.State = AuthMethodAPIKey
- }
-}
-
-func isOdd(n int) bool {
- return n%2 != 0
-}
@@ -1,267 +0,0 @@
-package claude
-
-import (
- "context"
- "fmt"
- "net/url"
-
- "charm.land/bubbles/v2/spinner"
- "charm.land/bubbles/v2/textinput"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/oauth"
- "github.com/charmbracelet/crush/internal/oauth/claude"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/pkg/browser"
- "github.com/zeebo/xxh3"
-)
-
-type OAuthState int
-
-const (
- OAuthStateURL OAuthState = iota
- OAuthStateCode
-)
-
-type OAuthValidationState int
-
-const (
- OAuthValidationStateNone OAuthValidationState = iota
- OAuthValidationStateVerifying
- OAuthValidationStateValid
- OAuthValidationStateError
-)
-
-type ValidationCompletedMsg struct {
- State OAuthValidationState
- Token *oauth.Token
-}
-
-type AuthenticationCompleteMsg struct{}
-
-type OAuth2 struct {
- State OAuthState
- ValidationState OAuthValidationState
- width int
- isOnboarding bool
-
- // URL page
- err error
- verifier string
- challenge string
- URL string
- urlId string
- token *oauth.Token
-
- // Code input page
- CodeInput textinput.Model
- spinner spinner.Model
-}
-
-func NewOAuth2() *OAuth2 {
- return &OAuth2{
- State: OAuthStateURL,
- }
-}
-
-func (o *OAuth2) Init() tea.Cmd {
- t := styles.CurrentTheme()
-
- verifier, challenge, err := claude.GetChallenge()
- if err != nil {
- o.err = err
- return nil
- }
-
- url, err := claude.AuthorizeURL(verifier, challenge)
- if err != nil {
- o.err = err
- return nil
- }
-
- o.verifier = verifier
- o.challenge = challenge
- o.URL = url
-
- h := xxh3.New()
- _, _ = h.WriteString(o.URL)
- o.urlId = fmt.Sprintf("id=%x", h.Sum(nil))
-
- o.CodeInput = textinput.New()
- o.CodeInput.Placeholder = "Paste or type"
- o.CodeInput.SetVirtualCursor(false)
- o.CodeInput.Prompt = "> "
- o.CodeInput.SetStyles(t.S().TextInput)
- o.CodeInput.SetWidth(50)
-
- o.spinner = spinner.New(
- spinner.WithSpinner(spinner.Dot),
- spinner.WithStyle(t.S().Base.Foreground(t.Green)),
- )
-
- return nil
-}
-
-func (o *OAuth2) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case ValidationCompletedMsg:
- o.ValidationState = msg.State
- o.token = msg.Token
- switch o.ValidationState {
- case OAuthValidationStateError:
- o.CodeInput.Focus()
- }
- o.updatePrompt()
- }
-
- if o.ValidationState == OAuthValidationStateVerifying {
- var cmd tea.Cmd
- o.spinner, cmd = o.spinner.Update(msg)
- cmds = append(cmds, cmd)
- o.updatePrompt()
- }
- {
- var cmd tea.Cmd
- o.CodeInput, cmd = o.CodeInput.Update(msg)
- cmds = append(cmds, cmd)
- }
-
- return o, tea.Batch(cmds...)
-}
-
-func (o *OAuth2) ValidationConfirm() (util.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch {
- case o.State == OAuthStateURL:
- _ = browser.OpenURL(o.URL)
- o.State = OAuthStateCode
- cmds = append(cmds, o.CodeInput.Focus())
- case o.ValidationState == OAuthValidationStateNone || o.ValidationState == OAuthValidationStateError:
- o.CodeInput.Blur()
- o.ValidationState = OAuthValidationStateVerifying
- cmds = append(cmds, o.spinner.Tick, o.validateCode)
- case o.ValidationState == OAuthValidationStateValid:
- cmds = append(cmds, func() tea.Msg { return AuthenticationCompleteMsg{} })
- }
-
- o.updatePrompt()
- return o, tea.Batch(cmds...)
-}
-
-func (o *OAuth2) View() string {
- t := styles.CurrentTheme()
-
- whiteStyle := lipgloss.NewStyle().Foreground(t.White)
- primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
- successStyle := lipgloss.NewStyle().Foreground(t.Success)
- errorStyle := lipgloss.NewStyle().Foreground(t.Error)
-
- titleStyle := whiteStyle
- if o.isOnboarding {
- titleStyle = primaryStyle
- }
-
- switch {
- case o.err != nil:
- return lipgloss.NewStyle().
- Margin(0, 1).
- Foreground(t.Error).
- Render(o.err.Error())
- case o.State == OAuthStateURL:
- heading := lipgloss.
- NewStyle().
- Margin(0, 1).
- Render(titleStyle.Render("Press enter key to open the following ") + successStyle.Render("URL") + titleStyle.Render(":"))
-
- return lipgloss.JoinVertical(
- lipgloss.Left,
- heading,
- "",
- lipgloss.NewStyle().
- Margin(0, 1).
- Foreground(t.FgMuted).
- Hyperlink(o.URL, o.urlId).
- Render(o.displayUrl()),
- )
- case o.State == OAuthStateCode:
- var heading string
-
- switch o.ValidationState {
- case OAuthValidationStateNone:
- st := lipgloss.NewStyle().Margin(0, 1)
- heading = st.Render(titleStyle.Render("Enter the ") + successStyle.Render("code") + titleStyle.Render(" you received."))
- case OAuthValidationStateVerifying:
- heading = titleStyle.Margin(0, 1).Render("Verifying...")
- case OAuthValidationStateValid:
- heading = successStyle.Margin(0, 1).Render("Validated.")
- case OAuthValidationStateError:
- heading = errorStyle.Margin(0, 1).Render("Invalid. Try again?")
- }
-
- return lipgloss.JoinVertical(
- lipgloss.Left,
- heading,
- "",
- " "+o.CodeInput.View(),
- )
- default:
- panic("claude oauth2: invalid state")
- }
-}
-
-func (o *OAuth2) SetDefaults() {
- o.State = OAuthStateURL
- o.ValidationState = OAuthValidationStateNone
- o.CodeInput.SetValue("")
- o.err = nil
-}
-
-func (o *OAuth2) SetWidth(w int) {
- o.width = w
- o.CodeInput.SetWidth(w - 4)
-}
-
-func (o *OAuth2) SetError(err error) {
- o.err = err
-}
-
-func (o *OAuth2) validateCode() tea.Msg {
- token, err := claude.ExchangeToken(context.Background(), o.CodeInput.Value(), o.verifier)
- if err != nil || token == nil {
- return ValidationCompletedMsg{State: OAuthValidationStateError}
- }
- return ValidationCompletedMsg{State: OAuthValidationStateValid, Token: token}
-}
-
-func (o *OAuth2) updatePrompt() {
- switch o.ValidationState {
- case OAuthValidationStateNone:
- o.CodeInput.Prompt = "> "
- case OAuthValidationStateVerifying:
- o.CodeInput.Prompt = o.spinner.View() + " "
- case OAuthValidationStateValid:
- o.CodeInput.Prompt = styles.CheckIcon + " "
- case OAuthValidationStateError:
- o.CodeInput.Prompt = styles.ErrorIcon + " "
- }
-}
-
-// Remove query params for display
-// e.g., "https://claude.ai/oauth/authorize?..." -> "https://claude.ai/oauth/authorize..."
-func (o *OAuth2) displayUrl() string {
- parsed, err := url.Parse(o.URL)
- if err != nil {
- return o.URL
- }
-
- if parsed.RawQuery != "" {
- parsed.RawQuery = ""
- return parsed.String() + "..."
- }
-
- return o.URL
-}
@@ -174,7 +174,7 @@ func (d *DeviceFlow) View() string {
freeMessage := lipgloss.NewStyle().
Margin(0, 1).
Width(d.width - 2).
- Render("You may be able to request free access if elegible. For more information, see:")
+ Render("You may be able to request free access if eligible. For more information, see:")
return lipgloss.JoinVertical(
lipgloss.Left,
message,
@@ -18,11 +18,6 @@ type KeyMap struct {
isHyperDeviceFlow bool
isCopilotDeviceFlow bool
isCopilotUnavailable bool
-
- isClaudeAuthChoiceHelp bool
- isClaudeOAuthHelp bool
- isClaudeOAuthURLState bool
- isClaudeOAuthHelpComplete bool
}
func DefaultKeyMap() KeyMap {
@@ -100,58 +95,6 @@ func (k KeyMap) ShortHelp() []key.Binding {
k.Close,
}
}
- if k.isClaudeAuthChoiceHelp {
- return []key.Binding{
- key.NewBinding(
- key.WithKeys("left", "right", "h", "l"),
- key.WithHelp("←→", "choose"),
- ),
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "accept"),
- ),
- key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "back"),
- ),
- }
- }
- if k.isClaudeOAuthHelp {
- if k.isClaudeOAuthHelpComplete {
- return []key.Binding{
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "close"),
- ),
- }
- }
-
- enterHelp := "submit"
- if k.isClaudeOAuthURLState {
- enterHelp = "open"
- }
-
- bindings := []key.Binding{
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", enterHelp),
- ),
- }
-
- if k.isClaudeOAuthURLState {
- bindings = append(bindings, key.NewBinding(
- key.WithKeys("c"),
- key.WithHelp("c", "copy url"),
- ))
- }
-
- bindings = append(bindings, key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "back"),
- ))
-
- return bindings
- }
if k.isAPIKeyHelp && !k.isAPIKeyValid {
return []key.Binding{
key.NewBinding(
@@ -10,13 +10,11 @@ import (
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
- "github.com/atotto/clipboard"
"github.com/charmbracelet/catwalk/pkg/catwalk"
hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
"github.com/charmbracelet/crush/internal/tui/exp/list"
@@ -81,12 +79,6 @@ type modelDialogCmp struct {
// Copilot device flow state
copilotDeviceFlow *copilot.DeviceFlow
showCopilotDeviceFlow bool
-
- // Claude state
- claudeAuthMethodChooser *claude.AuthMethodChooser
- claudeOAuth2 *claude.OAuth2
- showClaudeAuthMethodChooser bool
- showClaudeOAuth2 bool
}
func NewModelDialogCmp() ModelDialog {
@@ -111,9 +103,6 @@ func NewModelDialogCmp() ModelDialog {
width: defaultWidth,
keyMap: DefaultKeyMap(),
help: help,
-
- claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
- claudeOAuth2: claude.NewOAuth2(),
}
}
@@ -121,8 +110,6 @@ func (m *modelDialogCmp) Init() tea.Cmd {
return tea.Batch(
m.modelList.Init(),
m.apiKeyInput.Init(),
- m.claudeAuthMethodChooser.Init(),
- m.claudeOAuth2.Init(),
)
}
@@ -133,7 +120,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
m.wHeight = msg.Height
m.apiKeyInput.SetWidth(m.width - 2)
m.help.SetWidth(m.width - 2)
- m.claudeAuthMethodChooser.SetWidth(m.width - 2)
return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
case APIKeyStateChangeMsg:
u, cmd := m.apiKeyInput.Update(msg)
@@ -157,20 +143,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return m, nil
case copilot.DeviceFlowCompletedMsg:
return m, m.saveOauthTokenAndContinue(msg.Token, true)
- case claude.ValidationCompletedMsg:
- var cmds []tea.Cmd
- u, cmd := m.claudeOAuth2.Update(msg)
- m.claudeOAuth2 = u.(*claude.OAuth2)
- cmds = append(cmds, cmd)
-
- if msg.State == claude.OAuthValidationStateValid {
- cmds = append(cmds, m.saveOauthTokenAndContinue(msg.Token, false))
- m.keyMap.isClaudeOAuthHelpComplete = true
- }
-
- return m, tea.Batch(cmds...)
- case claude.AuthenticationCompleteMsg:
- return m, util.CmdHandler(dialogs.CloseDialogMsg{})
case tea.KeyPressMsg:
switch {
// Handle Hyper device flow keys
@@ -178,18 +150,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return m, m.hyperDeviceFlow.CopyCode()
case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showCopilotDeviceFlow:
return m, m.copilotDeviceFlow.CopyCode()
- case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
- return m, tea.Sequence(
- tea.SetClipboard(m.claudeOAuth2.URL),
- func() tea.Msg {
- _ = clipboard.WriteAll(m.claudeOAuth2.URL)
- return nil
- },
- util.ReportInfo("URL copied to clipboard"),
- )
- case key.Matches(msg, m.keyMap.Choose) && m.showClaudeAuthMethodChooser:
- m.claudeAuthMethodChooser.ToggleChoice()
- return m, nil
case key.Matches(msg, m.keyMap.Select):
// If showing device flow, enter copies code and opens URL
if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
@@ -209,37 +169,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}
askForApiKey := func() {
- m.keyMap.isClaudeAuthChoiceHelp = false
- m.keyMap.isClaudeOAuthHelp = false
m.keyMap.isAPIKeyHelp = true
m.showHyperDeviceFlow = false
m.showCopilotDeviceFlow = false
- m.showClaudeAuthMethodChooser = false
m.needsAPIKey = true
m.selectedModel = selectedItem
m.selectedModelType = modelType
m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
}
- if m.showClaudeAuthMethodChooser {
- switch m.claudeAuthMethodChooser.State {
- case claude.AuthMethodAPIKey:
- askForApiKey()
- case claude.AuthMethodOAuth2:
- m.selectedModel = selectedItem
- m.selectedModelType = modelType
- m.showClaudeAuthMethodChooser = false
- m.showClaudeOAuth2 = true
- m.keyMap.isClaudeAuthChoiceHelp = false
- m.keyMap.isClaudeOAuthHelp = true
- }
- return m, nil
- }
- if m.showClaudeOAuth2 {
- m2, cmd2 := m.claudeOAuth2.ValidationConfirm()
- m.claudeOAuth2 = m2.(*claude.OAuth2)
- return m, cmd2
- }
if m.isAPIKeyValid {
return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true)
}
@@ -298,10 +236,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
)
}
switch selectedItem.Provider.ID {
- case catwalk.InferenceProviderAnthropic:
- m.showClaudeAuthMethodChooser = true
- m.keyMap.isClaudeAuthChoiceHelp = true
- return m, nil
case hyperp.Name:
m.showHyperDeviceFlow = true
m.selectedModel = selectedItem
@@ -327,9 +261,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return m, nil
case key.Matches(msg, m.keyMap.Tab):
switch {
- case m.showClaudeAuthMethodChooser:
- m.claudeAuthMethodChooser.ToggleChoice()
- return m, nil
case m.needsAPIKey:
u, cmd := m.apiKeyInput.Update(msg)
m.apiKeyInput = u.(*APIKeyInput)
@@ -355,12 +286,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}
m.showCopilotDeviceFlow = false
m.selectedModel = nil
- case m.showClaudeAuthMethodChooser:
- m.claudeAuthMethodChooser.SetDefaults()
- m.showClaudeAuthMethodChooser = false
- m.keyMap.isClaudeAuthChoiceHelp = false
- m.keyMap.isClaudeOAuthHelp = false
- return m, nil
case m.needsAPIKey:
if m.isAPIKeyValid {
return m, nil
@@ -377,14 +302,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}
default:
switch {
- case m.showClaudeAuthMethodChooser:
- u, cmd := m.claudeAuthMethodChooser.Update(msg)
- m.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
- return m, cmd
- case m.showClaudeOAuth2:
- u, cmd := m.claudeOAuth2.Update(msg)
- m.claudeOAuth2 = u.(*claude.OAuth2)
- return m, cmd
case m.needsAPIKey:
u, cmd := m.apiKeyInput.Update(msg)
m.apiKeyInput = u.(*APIKeyInput)
@@ -397,10 +314,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}
case tea.PasteMsg:
switch {
- case m.showClaudeOAuth2:
- u, cmd := m.claudeOAuth2.Update(msg)
- m.claudeOAuth2 = u.(*claude.OAuth2)
- return m, cmd
case m.needsAPIKey:
u, cmd := m.apiKeyInput.Update(msg)
m.apiKeyInput = u.(*APIKeyInput)
@@ -433,10 +346,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
u, cmd := m.copilotDeviceFlow.Update(msg)
m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
return m, cmd
- case m.showClaudeOAuth2:
- u, cmd := m.claudeOAuth2.Update(msg)
- m.claudeOAuth2 = u.(*claude.OAuth2)
- return m, cmd
default:
u, cmd := m.apiKeyInput.Update(msg)
m.apiKeyInput = u.(*APIKeyInput)
@@ -483,27 +392,6 @@ func (m *modelDialogCmp) View() string {
m.keyMap.isCopilotUnavailable = false
switch {
- case m.showClaudeAuthMethodChooser:
- chooserView := m.claudeAuthMethodChooser.View()
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Let's Auth Anthropic", m.width-4)),
- chooserView,
- "",
- t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
- )
- return m.style().Render(content)
- case m.showClaudeOAuth2:
- m.keyMap.isClaudeOAuthURLState = m.claudeOAuth2.State == claude.OAuthStateURL
- oauth2View := m.claudeOAuth2.View()
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Let's Auth Anthropic", m.width-4)),
- oauth2View,
- "",
- t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
- )
- return m.style().Render(content)
case m.needsAPIKey:
// Show API key input
m.keyMap.isAPIKeyHelp = true
@@ -540,16 +428,6 @@ func (m *modelDialogCmp) Cursor() *tea.Cursor {
if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
return m.copilotDeviceFlow.Cursor()
}
- if m.showClaudeAuthMethodChooser {
- return nil
- }
- if m.showClaudeOAuth2 {
- if cursor := m.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
- cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
- return m.moveCursor(cursor)
- }
- return nil
- }
if m.needsAPIKey {
cursor := m.apiKeyInput.Cursor()
if cursor != nil {
@@ -11,7 +11,6 @@ import (
"github.com/charmbracelet/crush/internal/lsp"
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
)
// RenderOptions contains options for rendering LSP lists.
@@ -61,36 +60,23 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
// Calculate diagnostic counts if we have LSP clients
var extraContent string
if lspClients != nil {
- lspErrs := map[protocol.DiagnosticSeverity]int{
- protocol.SeverityError: 0,
- protocol.SeverityWarning: 0,
- protocol.SeverityHint: 0,
- protocol.SeverityInformation: 0,
- }
if client, ok := lspClients.Get(l.Name); ok {
- for _, diagnostics := range client.GetDiagnostics() {
- for _, diagnostic := range diagnostics {
- if severity, ok := lspErrs[diagnostic.Severity]; ok {
- lspErrs[diagnostic.Severity] = severity + 1
- }
- }
+ counts := client.GetDiagnosticCounts()
+ errs := []string{}
+ if counts.Error > 0 {
+ errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, counts.Error)))
}
+ if counts.Warning > 0 {
+ errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, counts.Warning)))
+ }
+ if counts.Hint > 0 {
+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, counts.Hint)))
+ }
+ if counts.Information > 0 {
+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, counts.Information)))
+ }
+ extraContent = strings.Join(errs, " ")
}
-
- errs := []string{}
- if lspErrs[protocol.SeverityError] > 0 {
- errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
- }
- if lspErrs[protocol.SeverityWarning] > 0 {
- errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
- }
- if lspErrs[protocol.SeverityHint] > 0 {
- errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
- }
- if lspErrs[protocol.SeverityInformation] > 0 {
- errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
- }
- extraContent = strings.Join(errs, " ")
}
lspList = append(lspList,
@@ -29,7 +29,6 @@ import (
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
@@ -337,9 +336,7 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
cmds = append(cmds, cmd)
return p, tea.Batch(cmds...)
- case claude.ValidationCompletedMsg,
- claude.AuthenticationCompleteMsg,
- hyper.DeviceFlowCompletedMsg,
+ case hyper.DeviceFlowCompletedMsg,
hyper.DeviceAuthInitiatedMsg,
hyper.DeviceFlowErrorMsg,
copilot.DeviceAuthInitiatedMsg,
@@ -1037,53 +1034,8 @@ func (p *chatPage) Help() help.KeyMap {
var shortList []key.Binding
var fullList [][]key.Binding
switch {
- case p.isOnboarding && p.splash.IsShowingClaudeAuthMethodChooser():
- shortList = append(shortList,
- // Choose auth method
- key.NewBinding(
- key.WithKeys("left", "right", "tab"),
- key.WithHelp("←→/tab", "choose"),
- ),
- // Accept selection
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "accept"),
- ),
- // Go back
- key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "back"),
- ),
- // Quit
- key.NewBinding(
- key.WithKeys("ctrl+c"),
- key.WithHelp("ctrl+c", "quit"),
- ),
- )
- // keep them the same
- for _, v := range shortList {
- fullList = append(fullList, []key.Binding{v})
- }
- case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
+ case p.isOnboarding:
switch {
- case p.splash.IsClaudeOAuthURLState():
- shortList = append(shortList,
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "open"),
- ),
- key.NewBinding(
- key.WithKeys("c"),
- key.WithHelp("c", "copy url"),
- ),
- )
- case p.splash.IsClaudeOAuthComplete():
- shortList = append(shortList,
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "continue"),
- ),
- )
case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
shortList = append(shortList,
key.NewBinding(
@@ -4,6 +4,7 @@ import (
"fmt"
"image/color"
"strings"
+ "sync"
"charm.land/bubbles/v2/filepicker"
"charm.land/bubbles/v2/help"
@@ -97,7 +98,8 @@ type Theme struct {
AuthBorderUnselected lipgloss.Style
AuthTextUnselected lipgloss.Style
- styles *Styles
+ styles *Styles
+ stylesOnce sync.Once
}
type Styles struct {
@@ -134,9 +136,9 @@ type Styles struct {
}
func (t *Theme) S() *Styles {
- if t.styles == nil {
+ t.stylesOnce.Do(func() {
t.styles = t.buildStyles()
- }
+ })
return t.styles
}
@@ -500,27 +502,31 @@ type Manager struct {
current *Theme
}
-var defaultManager *Manager
+var (
+ defaultManager *Manager
+ defaultManagerOnce sync.Once
+)
+
+func initDefaultManager() *Manager {
+ defaultManagerOnce.Do(func() {
+ defaultManager = newManager()
+ })
+ return defaultManager
+}
func SetDefaultManager(m *Manager) {
defaultManager = m
}
func DefaultManager() *Manager {
- if defaultManager == nil {
- defaultManager = NewManager()
- }
- return defaultManager
+ return initDefaultManager()
}
func CurrentTheme() *Theme {
- if defaultManager == nil {
- defaultManager = NewManager()
- }
- return defaultManager.Current()
+ return initDefaultManager().Current()
}
-func NewManager() *Manager {
+func newManager() *Manager {
m := &Manager{
themes: make(map[string]*Theme),
}
@@ -106,7 +106,7 @@ func (c *github) Latest(ctx context.Context) (*Release, error) {
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("github api returned status %d: %s", resp.StatusCode, string(body))
+ return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
}
var release Release
@@ -421,6 +421,11 @@
"description": "Disable providers auto-update",
"default": false
},
+ "disable_default_providers": {
+ "type": "boolean",
+ "description": "Ignore all default/embedded providers. When enabled",
+ "default": false
+ },
"attribution": {
"$ref": "#/$defs/Attribution",
"description": "Attribution settings for generated content"