diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 7ab467b0f9ea6d6f052e7acb7b35b43a59bc6e49..5929987f916594da1109eee2082c154620edf660 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -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 } ] } \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3511f7fe0c4f487eb3fc9009795361ada8e2eff7..39b5923298e2f7fa8d5452327a6e8b2a08f0df97 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 ./... diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000000000000000000000000000000000000..3a90ea316c3d86f5b2f93224fd2b35eaa572e704 --- /dev/null +++ b/.github/workflows/security.yml @@ -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 diff --git a/README.md b/README.md index 4d876ef648b8237a4e2a172c23acfe5e05ec386b..6a57c7934d0714cd4e0ae3f30fab108d03196b98 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Crush

- Charm Crush Logo
+ Charm Crush Logo
Latest Release Build Status

@@ -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
Nix (NUR) -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. diff --git a/Taskfile.yaml b/Taskfile.yaml index 2f5574f7ab1f07a03f47e8534d477afd293d9248..0043f4f033e455a5800da2431848e620c37a0f5a 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -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 diff --git a/go.mod b/go.mod index 8877336bd0a42158b2a96b3c34c6544d190f2151..770fdd7d04c36909edcfabff1777a13b1823c519 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 29ac00482dedaeb5f7444810b7194a367c6f67a9..5e1cb24cf95c53384a2dea077c3633c267c2d11d 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 79c8fbeecf2224712e10ddde2453459f3c3e8dc7..c916cfd886372ab86f6d1fbb0e8b7bde2c87dabb 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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 \n\n", 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() +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index ca5bb10dca1bc1096429e99dc217389d30e90248..d61395a6080c6d9052545b1f82024f324766632b 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -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) + } + }) + } +} diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 333ec7926f80735c3798c524378964a8e41fe3e4..89d3535720f8452111f12f4df4eb691e39253bed 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -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 } diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 12cd61015c4210b886bae438b12451f32a0aebf2..97ac8063baa9e085843ff19852949b42ce066126 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -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 diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index a62c179dda7d899c20eda3ce16d1c3e82e001e50..4a25c84201504dacea52cddc4f6dffa1cf8c2bec 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -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: diff --git a/internal/agent/event.go b/internal/agent/event.go index bf36ec84bf4270bd2e63ae0efae0440474288565..3f6c640f6a983c515034e0698676632d0cb57824 100644 --- a/internal/agent/event.go +++ b/internal/agent/event.go @@ -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{ diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go index ea2f4a18eaeec017f5f3f02576504a424f50bbf1..03278ae99f87608c65263b0ffef7fb473cd58e31 100644 --- a/internal/agent/hyper/provider.go +++ b/internal/agent/hyper/provider.go @@ -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" } diff --git a/internal/agent/tools/bash.go b/internal/agent/tools/bash.go index ef800a733835df56a0e44d93e065b60b5411a039..6855f5f8d409c49fc270f038f8a75a14d56e0b36 100644 --- a/internal/agent/tools/bash.go +++ b/internal/agent/tools/bash.go @@ -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 } diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index de1fc9a13c95296bb8e637f56b4d0abc4f25c34b..85e8b8d0f7d997f8db83f0d6176ce30c644b86f0 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -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" diff --git a/internal/agent/tools/download.go b/internal/agent/tools/download.go index 353b312a29d410c6485f76fc8dd42a4b9dcdefb1..8f3f224b9e5647911d3c7e1cc5a668eea18b1785 100644 --- a/internal/agent/tools/download.go +++ b/internal/agent/tools/download.go @@ -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 } diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index e4503e8127a750647c659353a018d36ee42643a1..8d8bb87be59dbfb0e038ba40e2b09a3c14d7624b 100644 --- a/internal/agent/tools/edit.go +++ b/internal/agent/tools/edit.go @@ -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) diff --git a/internal/agent/tools/fetch.go b/internal/agent/tools/fetch.go index b23da7099be7ad0b5e3cc7076426c7494e8a3202..fdb63f057958e5e5a67affe0783a452c27febf41 100644 --- a/internal/agent/tools/fetch.go +++ b/internal/agent/tools/fetch.go @@ -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 = "\n\n" + body + "\n\n" } } - // 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) } diff --git a/internal/agent/tools/fetch_helpers.go b/internal/agent/tools/fetch_helpers.go index 34eb3b2fcd4424997338307560661172ed5f6662..dfcf31c882431ab468f8847fb4bedf609ffeb756 100644 --- a/internal/agent/tools/fetch_helpers.go +++ b/internal/agent/tools/fetch_helpers.go @@ -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") diff --git a/internal/agent/tools/ls.go b/internal/agent/tools/ls.go index 2a6627741256339a319ec734c4ff766b041e5670..20bb1bad4c2d92bb02e564045d4553206ffd12a1 100644 --- a/internal/agent/tools/ls.go +++ b/internal/agent/tools/ls.go @@ -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) } diff --git a/internal/agent/tools/mcp-tools.go b/internal/agent/tools/mcp-tools.go index 5b4302cc5e16adedea18bdc767d2312f8d920f82..fa55f03728639a09e6bd2f150338238d30120883 100644 --- a/internal/agent/tools/mcp-tools.go +++ b/internal/agent/tools/mcp-tools.go @@ -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 } diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index be27ce3f8ae5b9b7f425e496a1726bc23eaf3aae..e1e7d609efc86d0dcb510fa5963552f7d487a134 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -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) { diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go index 9136c37fadb914cb1c560e3fa5f2b6208fc3ead5..0640228d23230e6a49d8e1405f371c099031fbf7 100644 --- a/internal/agent/tools/multiedit.go +++ b/internal/agent/tools/multiedit.go @@ -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 } diff --git a/internal/agent/tools/multiedit_test.go b/internal/agent/tools/multiedit_test.go index 36d0a0d469f67aa11cf36cd0bce3efffb4bab683..b6d575435e63dcd62a4dc9a7efb76cf13c14ad05 100644 --- a/internal/agent/tools/multiedit_test.go +++ b/internal/agent/tools/multiedit_test.go @@ -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) {} diff --git a/internal/agent/tools/search.go b/internal/agent/tools/search.go index 64c3219f169b1c8ce8284b86203e84bfb19d0e59..9df7be8764ab952a23f25d624f72748696a86aac 100644 --- a/internal/agent/tools/search.go +++ b/internal/agent/tools/search.go @@ -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() +} diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 7129a91b4b526bfdd27c97987b84aeae38d33068..35865cf43f7c587d60764b3ed177374940bbe2dc 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -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 } diff --git a/internal/agent/tools/web_search.go b/internal/agent/tools/web_search.go index b604c9051b4f5b0039431c01bea0b150a318740e..5ce9280c013cdd100f6d7734c969723b21e7e3bf 100644 --- a/internal/agent/tools/web_search.go +++ b/internal/agent/tools/web_search.go @@ -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 } diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go index 4ffd44a0553d1a1646d20dac557ab4e1bc47f45a..8becaea3c08157897dcece7b3d5d4de5cb2ee929 100644 --- a/internal/agent/tools/write.go +++ b/internal/agent/tools/write.go @@ -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) diff --git a/internal/app/app.go b/internal/app/app.go index 436579d0b9593a1f9fd36606ae1e1b81fd89e737..08762f863a7d9cf77751d0c2c4095591f002eab0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) } }) } diff --git a/internal/app/lsp.go b/internal/app/lsp.go index dfebfe565d96ed2798e1e89cb7e82aaa7b78c13f..23a5447af92872223f91d3283cf6663aae0d1d07 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -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) diff --git a/internal/app/lsp_events.go b/internal/app/lsp_events.go index 08e54582b95d8db725bffc7ff8bd43d4a37528b1..5292983d46cf867b9380ad45f7831007da54f0d7 100644 --- a/internal/app/lsp_events.go +++ b/internal/app/lsp_events.go @@ -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 diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 0d6c910f407e63d9a52e14878769a0381779cb46..b38eaeed00ad1def862d83145f256bc219c27fda 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -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)) } diff --git a/internal/config/attribution_migration_test.go b/internal/config/attribution_migration_test.go index 6c891d92a9a29604d9d6c751e0d15df6edcf3598..cc8c9e2e278b89ee2f86996975774abe18413843 100644 --- a/internal/config/attribution_migration_test.go +++ b/internal/config/attribution_migration_test.go @@ -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(), "") diff --git a/internal/config/config.go b/internal/config/config.go index e68ad8c27ca7e3c2313a3b18b48bcbedc3d677e9..2c414e3e9e35d6f232e00762f50aca1066aca321 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/config/load.go b/internal/config/load.go index 1747e3ba8fe94700f2ca249443926491175f6f66..25139cb5f4b2ba8013525bfde025f04cb267d1b8 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -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 { diff --git a/internal/config/load_bench_test.go b/internal/config/load_bench_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3df43946d438fd478b63a1dfd242ee4c35e71896 --- /dev/null +++ b/internal/config/load_bench_test.go @@ -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) + } + } +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 4819a195df5031a0e179903be013a89ca038791d..8924475ef9c652ea1962e4f032a0e62e560bce7a 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -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{ diff --git a/internal/config/merge.go b/internal/config/merge.go deleted file mode 100644 index 3c9b7d6283a193166ad50730b28853a909f5158a..0000000000000000000000000000000000000000 --- a/internal/config/merge.go +++ /dev/null @@ -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 -} diff --git a/internal/config/merge_test.go b/internal/config/merge_test.go deleted file mode 100644 index 1b721bf2e8e4b4596025c2c773bec0093778f430..0000000000000000000000000000000000000000 --- a/internal/config/merge_test.go +++ /dev/null @@ -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)) - } -} diff --git a/internal/csync/maps.go b/internal/csync/maps.go index 1fd2005790014b2ce4bd5a78dbb7931d54cbe66c..d5856db463194f4aefc02794194992e7bb99a7ce 100644 --- a/internal/csync/maps.go +++ b/internal/csync/maps.go @@ -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) { diff --git a/internal/csync/maps_test.go b/internal/csync/maps_test.go index 4c590f008dad91e8dcbc40d1b90d87ef1b3e5750..31e6fa0c3aef18a04c61ea3d4d36b5187228c3ff 100644 --- a/internal/csync/maps_test.go +++ b/internal/csync/maps_test.go @@ -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") diff --git a/internal/csync/slices.go b/internal/csync/slices.go index c5c635683e70046694f1cdf647aac8cb425abd24..fcce9881b6e27021adcc9462b123f49d469dcd9f 100644 --- a/internal/csync/slices.go +++ b/internal/csync/slices.go @@ -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 +} diff --git a/internal/csync/slices_test.go b/internal/csync/slices_test.go index 85aedbaba40103ff9a8979e5c70299223f74591f..c7946ac6f1a84614def05b7b6e7e9b0ed11b3a73 100644 --- a/internal/csync/slices_test.go +++ b/internal/csync/slices_test.go @@ -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) diff --git a/internal/csync/value.go b/internal/csync/value.go new file mode 100644 index 0000000000000000000000000000000000000000..17528a281e0d34d49b206a7c3901b892370c18ba --- /dev/null +++ b/internal/csync/value.go @@ -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 +} diff --git a/internal/csync/value_test.go b/internal/csync/value_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3fa41d85144ea9373c7d440238c0321f52286330 --- /dev/null +++ b/internal/csync/value_test.go @@ -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) +} diff --git a/internal/csync/versionedmap.go b/internal/csync/versionedmap.go index f0f4e0249c3b0102976840bd82400e18c1703c47..6ed996b2ff8d1380aa7fd22cab57342bf71e4a8f 100644 --- a/internal/csync/versionedmap.go +++ b/internal/csync/versionedmap.go @@ -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() diff --git a/internal/db/connect.go b/internal/db/connect.go index bfe768c7ae9a399afd61a9d0692841fbacbe164c..20f0c3f31b1506e32ed9d53327d839ac7616bbc9 100644 --- a/internal/db/connect.go +++ b/internal/db/connect.go @@ -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) diff --git a/internal/db/connect_modernc.go b/internal/db/connect_modernc.go new file mode 100644 index 0000000000000000000000000000000000000000..303c4e9a1108562d5060699381dcd9d8c9088d8a --- /dev/null +++ b/internal/db/connect_modernc.go @@ -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 +} diff --git a/internal/db/connect_ncruces.go b/internal/db/connect_ncruces.go new file mode 100644 index 0000000000000000000000000000000000000000..ceeb7233a45fff443c13ae7a8dccf740dbd5b782 --- /dev/null +++ b/internal/db/connect_ncruces.go @@ -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 +} diff --git a/internal/event/event.go b/internal/event/event.go index 1dee1d49113684d80a5d3f390b7f912d22d7231d..674586b06bee03f22c1bd880a5bd39b740c75f66 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -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() { diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 7d914d9a52ce75f621715273e8f6b9588aa912b7..79220cc1f315fec30a1bee2aa0dcd106bc311a02 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -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. diff --git a/internal/message/attachment.go b/internal/message/attachment.go index 0e3b70a8766c74d37399c1ba8c38fe19e74f871d..b04863f39cc5b266662395344d5227cfa12f4188 100644 --- a/internal/message/attachment.go +++ b/internal/message/attachment.go @@ -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() + }) +} diff --git a/internal/message/content.go b/internal/message/content.go index 6c03d42aed05a7772f37d15dc782bf96c8b69685..3fed1f06019c855d30af9d5583e6a7b63fcbd508 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -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 += "\nThe files below have been attached by the user, consider them in your response\n" + sb.WriteString("\nThe files below have been attached by the user, consider them in your response\n") addedAttachments = true } - tag := `\n` if content.FilePath != "" { - tag = fmt.Sprintf("\n", content.FilePath) + fmt.Fprintf(&sb, "\n", content.FilePath) + } else { + sb.WriteString("\n") } - prompt += tag - prompt += "\n" + string(content.Content) + "\n\n" + sb.WriteString("\n") + sb.Write(content.Content) + sb.WriteString("\n\n") } - return prompt + return sb.String() } func (m *Message) ToAIMessage() []fantasy.Message { diff --git a/internal/message/content_test.go b/internal/message/content_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7e9e273c57e4b6cee2df8cd6b74bf455797bce36 --- /dev/null +++ b/internal/message/content_test.go @@ -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) + } + }) + } +} diff --git a/internal/oauth/claude/challenge.go b/internal/oauth/claude/challenge.go deleted file mode 100644 index ec9ed3c5d17e91fc5dc8c33f44f3d6a4ce4aa244..0000000000000000000000000000000000000000 --- a/internal/oauth/claude/challenge.go +++ /dev/null @@ -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 -} diff --git a/internal/oauth/claude/oauth.go b/internal/oauth/claude/oauth.go deleted file mode 100644 index b3c47960453385395ec2b6988229d0d6e5e3eae4..0000000000000000000000000000000000000000 --- a/internal/oauth/claude/oauth.go +++ /dev/null @@ -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) -} diff --git a/internal/oauth/copilot/client.go b/internal/oauth/copilot/client.go index f76f3bf640c4331968b4173cf0d48e0dbc69aed2..fd243f78b477465063c369dc4dc8f1ff38b72a8c 100644 --- a/internal/oauth/copilot/client.go +++ b/internal/oauth/copilot/client.go @@ -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 diff --git a/internal/permission/permission.go b/internal/permission/permission.go index 829dd2ed90abf4d45b63481eacebb492cadabdfd..e1bf1bae14b8473989b1c0890c58188591123d71 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -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) { diff --git a/internal/permission/permission_test.go b/internal/permission/permission_test.go index d1ccd286836768f1bc1119966568941f7494affd..79930f3ae1e2ef15257f09724fef64d3ea28dada 100644 --- a/internal/permission/permission_test.go +++ b/internal/permission/permission_test.go @@ -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") }) } diff --git a/internal/shell/background.go b/internal/shell/background.go index 37dd5d909adb0ef2230a4a84ab394851dd8167a4..cb1855836f64bdd56a90802c2bbb939a5a514100 100644 --- a/internal/shell/background.go +++ b/internal/shell/background.go @@ -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 } } diff --git a/internal/shell/background_test.go b/internal/shell/background_test.go index 5149861d94e457e8a78650c48d9c6765a57d369e..7c521bc1477b07775cffb69f310fa83d710d4634 100644 --- a/internal/shell/background_test.go +++ b/internal/shell/background_test.go @@ -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", "") diff --git a/internal/shell/shell.go b/internal/shell/shell.go index f9f4656b82bbb6ee14b38469a20d493d98354b4a..e5a54f01c403ae1b8de681616c5d693bc842ac14 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -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) + } } } diff --git a/internal/skills/skills.go b/internal/skills/skills.go index cba0b994d188e184fdcb55cf98bd080764e34327..e0488cff4a8c42ceee68a6a89608bd90acafdb2e 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -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() diff --git a/internal/tui/components/chat/editor/clipboard.go b/internal/tui/components/chat/editor/clipboard.go new file mode 100644 index 0000000000000000000000000000000000000000..de4b95da3cab6069bf31f61b5fb9e2908f970c07 --- /dev/null +++ b/internal/tui/components/chat/editor/clipboard.go @@ -0,0 +1,8 @@ +package editor + +type clipboardFormat int + +const ( + clipboardFormatText clipboardFormat = iota + clipboardFormatImage +) diff --git a/internal/tui/components/chat/editor/clipboard_not_supported.go b/internal/tui/components/chat/editor/clipboard_not_supported.go new file mode 100644 index 0000000000000000000000000000000000000000..dfecc09dca05ca5d07dd1db109fe3178f6c357b8 --- /dev/null +++ b/internal/tui/components/chat/editor/clipboard_not_supported.go @@ -0,0 +1,7 @@ +//go:build !(darwin || linux || windows) || arm || 386 || ios || android + +package editor + +func readClipboard(clipboardFormat) ([]byte, error) { + return nil, errClipboardPlatformUnsupported +} diff --git a/internal/tui/components/chat/editor/clipboard_supported.go b/internal/tui/components/chat/editor/clipboard_supported.go new file mode 100644 index 0000000000000000000000000000000000000000..175a4b4ea4dfaea03916dc1012c313201f1846f8 --- /dev/null +++ b/internal/tui/components/chat/editor/clipboard_supported.go @@ -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 +} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 972824be0599fb37651f8b607a90114387a73f3c..8f7c43c76a965539db3c3d6de4f46377c8a10a5c 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -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) { diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go index 0ba4571888e547b1c4a85e7ee9dd73ff07ce13d2..c20df5cc1c071deab83754430543b9be2381127c 100644 --- a/internal/tui/components/chat/editor/keys.go +++ b/internal/tui/components/chat/editor/keys.go @@ -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, diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go index 59389815ac63ac127ac000abf872b000eb8f2347..c8848440b1193fda9a7b5df4b31e03edeaf744c4 100644 --- a/internal/tui/components/chat/header/header.go +++ b/internal/tui/components/chat/header/header.go @@ -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 { diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 1359823edb7a783cd23b600e1ddae3870f2a2107..b4db149946fe0a1f67c957eeb04da2966e1f5f28 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -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...) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 8a053294da3e342661c0db8b38cd371103c943b1..517f6d0930c46cf3d2e9f656c22515de4e9785fd 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -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 } diff --git a/internal/tui/components/dialogs/claude/method.go b/internal/tui/components/dialogs/claude/method.go deleted file mode 100644 index 071d437799dcd2e3d5b9e60c33c7173c18577016..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/claude/method.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/dialogs/claude/oauth.go b/internal/tui/components/dialogs/claude/oauth.go deleted file mode 100644 index f8da5b4fffbc75708676a1545f9a6719b7e2f198..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/claude/oauth.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/dialogs/copilot/device_flow.go b/internal/tui/components/dialogs/copilot/device_flow.go index d3f792291e4bce77dc5ceacb1aa1200a111981dc..d8a2850c3ea151021958a07b350df879d1db4554 100644 --- a/internal/tui/components/dialogs/copilot/device_flow.go +++ b/internal/tui/components/dialogs/copilot/device_flow.go @@ -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, diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go index eda235aebb858fef21c582921cfb9e305a6fed19..ff81404b1f1937fff09d917bf3a9e3b24f4d38c9 100644 --- a/internal/tui/components/dialogs/models/keys.go +++ b/internal/tui/components/dialogs/models/keys.go @@ -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( diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index afca44ecd5e64e42e3b375311d3c5ff8efaedd5b..b06b4b475a9ababbda9e0702fc5552b0959741ba 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -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 { diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go index 18c3f74b71768b88d068093759245615d2f7a284..f9118143cbfd9a7bf19aa569bc85448746debecd 100644 --- a/internal/tui/components/lsp/lsp.go +++ b/internal/tui/components/lsp/lsp.go @@ -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, diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index d86e60c8cdcb0f6d87b7c97a6e40e83bddffeace..9a4b69f5507fbb62b7ee93df6326f94cf79d22ad 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -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( diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index f87ffd9de8b324cec4dcfd8b7cee61f71e0390eb..b03603c57439f5f950f9860d3287b0f9d13742e5 100644 --- a/internal/tui/styles/theme.go +++ b/internal/tui/styles/theme.go @@ -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), } diff --git a/internal/update/update.go b/internal/update/update.go index a813fe3516dc28233e3df01c77d4d62d4d97db18..dd733da542259e60cc166c3ef48e645a527a548f 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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 diff --git a/schema.json b/schema.json index a2d88bfd5f4be210534209694a9f0c0eb5c993c0..d92c398cc84704e69975f501c5f96e26ebfa7d1e 100644 --- a/schema.json +++ b/schema.json @@ -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"