diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 4026b8c990a227a4a99f6193ff798f4f383e5f09..c7037135e5d00cb257235b8574d145dec774b711 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1287,6 +1287,46 @@ "created_at": "2026-02-23T13:12:50Z", "repoId": 987670088, "pullRequestNo": 2293 + }, + { + "name": "mavaa", + "id": 1224973, + "comment_id": 3966463081, + "created_at": "2026-02-26T12:56:51Z", + "repoId": 987670088, + "pullRequestNo": 2314 + }, + { + "name": "aisk", + "id": 699636, + "comment_id": 3968065934, + "created_at": "2026-02-26T17:23:44Z", + "repoId": 987670088, + "pullRequestNo": 2315 + }, + { + "name": "detro", + "id": 114508, + "comment_id": 3976601253, + "created_at": "2026-02-28T07:36:40Z", + "repoId": 987670088, + "pullRequestNo": 2326 + }, + { + "name": "taoeffect", + "id": 138706, + "comment_id": 3979067985, + "created_at": "2026-03-01T04:22:00Z", + "repoId": 987670088, + "pullRequestNo": 2333 + }, + { + "name": "vmfu", + "id": 80844805, + "comment_id": 3980616480, + "created_at": "2026-03-01T17:51:04Z", + "repoId": 987670088, + "pullRequestNo": 2337 } ] } \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml index a0270f4f4ad0873aa7eb04b5b34bcb703ef859b9..8111078c123bdf6df4e70460bff5e6cbc489f5e1 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -55,6 +55,8 @@ - "/gemini/i" "provider: google vertex": - "/vertex/i" +"provider: hyper": + - "/hyper/i" "provider: kimi": - "/kimi/i" "provider: minimax": diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 07481c3d96382c8f06af322bc5eb6c13e990d37a..670c9908d7b39276985ed26219c09d2114ea58f7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version-file: go.mod - run: go mod tidy diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d71849463f72b5e286aff46c42260d30322b06a1..9dc35a022bba53d4acf330b293d4ed42e8e735d5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,5 +8,5 @@ jobs: uses: charmbracelet/meta/.github/workflows/lint.yml@main with: golangci_path: .golangci.yml - golangci_version: v2.9 + golangci_version: v2.10 timeout: 10m diff --git a/.github/workflows/schema-update.yml b/.github/workflows/schema-update.yml index 967c9b47af65a6f912e95c22784fbc93aae4f275..4a9e18ab350ca1b82a356a7011f49e7cf00d581d 100644 --- a/.github/workflows/schema-update.yml +++ b/.github/workflows/schema-update.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version-file: go.mod - run: go run . schema > ./schema.json diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3d488dfaf901404d00a29a6aa52a32ba01f360e3..ba2c56a67e35665b955683fcec659e1da0282ede 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -65,7 +65,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: 1.26.0 - name: Install govulncheck diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 3c139d5b7664315e7b66271df5ec0fa2fb52ca4f..9250dbff126c3e2662082170b54f526101bdfddd 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -22,7 +22,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version-file: go.mod - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 diff --git a/.goreleaser.yml b/.goreleaser.yml index 0ba2b1eccdf6de70c3e39d9111074a84658bd2a3..69f81dad90fe162cb38309abb5960d0f9aa27361 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -297,6 +297,8 @@ winget: owner: microsoft name: winget-pkgs branch: master + body: | + /cc @andreynering changelog: sort: asc diff --git a/go.mod b/go.mod index c47e9b613eac3b7e1fdce1063b8ce623d2fb2c35..26fcbaf6e17fe9032e8c0f063eb69a360ca70734 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.26.0 require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.0 - charm.land/catwalk v0.21.1 - charm.land/fantasy v0.9.0 + charm.land/catwalk v0.25.3 + charm.land/fantasy v0.11.0 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b charm.land/lipgloss/v2 v2.0.0 charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da @@ -31,21 +31,21 @@ require ( github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/slice v0.0.0-20260209194814-eeb2896ac759 github.com/charmbracelet/x/exp/strings v0.1.0 - github.com/charmbracelet/x/powernap v0.1.0 + github.com/charmbracelet/x/powernap v0.1.3 github.com/charmbracelet/x/term v0.2.2 github.com/clipperhouse/displaywidth v0.11.0 github.com/clipperhouse/uax29/v2 v2.7.0 github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.1 - github.com/go-git/go-git/v5 v5.16.5 + github.com/go-git/go-git/v5 v5.17.0 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mattn/go-isatty v0.0.20 - github.com/modelcontextprotocol/go-sdk v1.3.1 + github.com/modelcontextprotocol/go-sdk v1.4.0 github.com/ncruces/go-sqlite3 v0.30.5 github.com/nxadm/tail v1.4.11 github.com/openai/openai-go/v2 v2.7.1 @@ -62,7 +62,7 @@ require ( github.com/tidwall/sjson v1.2.5 github.com/zeebo/xxh3 v1.1.0 go.uber.org/goleak v1.3.0 - golang.org/x/net v0.50.0 + golang.org/x/net v0.51.0 golang.org/x/sync v0.19.0 golang.org/x/text v0.34.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -80,36 +80,36 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.9 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.9 // 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/config v1.32.10 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // 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.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.10 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // 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/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect + github.com/aws/smithy-go v1.24.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect + github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/ebitengine/purego v0.10.0-alpha.4 // indirect + github.com/ebitengine/purego v0.10.0-alpha.5 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-billy/v5 v5.8.0 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -117,7 +117,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect @@ -129,9 +128,9 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kaptinlin/go-i18n v0.2.9 // indirect + github.com/kaptinlin/go-i18n v0.2.11 // indirect github.com/kaptinlin/jsonpointer v0.4.16 // indirect - github.com/kaptinlin/jsonschema v0.7.2 // indirect + github.com/kaptinlin/jsonschema v0.7.3 // indirect github.com/kaptinlin/messageformat-go v0.4.18 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect @@ -180,8 +179,8 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/api v0.266.0 // indirect - google.golang.org/genai v1.47.0 // indirect + google.golang.org/api v0.267.0 // indirect + google.golang.org/genai v1.48.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 685c4df3f6ed6efcab0002e61088fa7ecb37c49b..d759ae75ee70b1266f6a270d6e4784040afe348d 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,10 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ= charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= -charm.land/catwalk v0.21.1 h1:CO6GDgfl6u0Gx6v3vC64B8DEnX+PhjDxX7IrVyu3Feg= -charm.land/catwalk v0.21.1/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= -charm.land/fantasy v0.9.0 h1:2KzDYZC3IDb6T8KhWn4akqDHoU5Evr+VwL2xbaWtXmM= -charm.land/fantasy v0.9.0/go.mod h1:vpR/vcgCtKZ5SWHNbW/5c1b+DMDNNO15j+t/evoQb/4= +charm.land/catwalk v0.25.3 h1:mkeICGwUPR9ZeOKNaeRmUrTyDJazTFYiNFWSeyjhM1A= +charm.land/catwalk v0.25.3/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= +charm.land/fantasy v0.11.0 h1:KrYa7B3JMCViXsbDyho9vLdzoml9Id8OgyytowrmkNY= +charm.land/fantasy v0.11.0/go.mod h1:NtQpqji9blpicYopEzcbgj8mIR4fOMjwK0wyr/D9D5M= charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0= charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8= charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= @@ -48,36 +48,36 @@ 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.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 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= +github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A= -github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= -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/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI= +github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw= +github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k= 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.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.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/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/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= +github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= +github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymanbagabas/go-nativeclipboard v0.1.3 h1:FmAWHPTwneAixu7uGDn3cL42xPlUCdNp2J8egMn3P1k= github.com/aymanbagabas/go-nativeclipboard v0.1.3/go.mod h1:2o7MyZwwi4pmXXpOpvOS5FwaHyoCIUks0ktjUvB0EoE= github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM= @@ -94,8 +94,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= @@ -120,8 +120,8 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9 github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= 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.1.0 h1:xJPM8szKu3UY4ZuW3puc8R7Hpftq2nLIygyRe3EGUoE= -github.com/charmbracelet/x/powernap v0.1.0/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= +github.com/charmbracelet/x/powernap v0.1.3 h1:rmxdQelSPB1QAgLRNMLOrgCTq3q2RXoLOJ2ZTwKG17g= +github.com/charmbracelet/x/powernap v0.1.3/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= @@ -147,15 +147,17 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= 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.4 h1:JzPbdf+cqbyT9sZtP4xnqelwUXwf7LvD8xKS6+ofTds= -github.com/ebitengine/purego v0.10.0-alpha.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0-alpha.5 h1:IUIZ1pu0wnpxrn7o6utj8AeoZBS2upI11kLcddBF414= +github.com/ebitengine/purego v0.10.0-alpha.5/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= @@ -163,10 +165,10 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= -github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= -github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= +github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -225,12 +227,12 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4= github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kaptinlin/go-i18n v0.2.9 h1:96TWNQI0j5nPhcmeFaCyX8SfyNhA0CTjeilLTy7ol9M= -github.com/kaptinlin/go-i18n v0.2.9/go.mod h1:Sm0GTLS6hbFDrUQahycQfHF377WR9VF5eDgWrwljCAk= +github.com/kaptinlin/go-i18n v0.2.11 h1:OayNt8mWt8nDaqAOp09/C1VG9Y5u8LpQnnxbyGARDV4= +github.com/kaptinlin/go-i18n v0.2.11/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU= github.com/kaptinlin/jsonpointer v0.4.16 h1:Ux4w4FY+uLv+K+TxaCJtM/TpPv+1+eS6gH4Z9/uhOuA= github.com/kaptinlin/jsonpointer v0.4.16/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU= -github.com/kaptinlin/jsonschema v0.7.2 h1:I4AiYZ/be3gtWi4Mb7vtY8W6zN6f4YvT2eHCUXXJfmQ= -github.com/kaptinlin/jsonschema v0.7.2/go.mod h1:Y6SZ/x3m9LZzEQY/NxCjHCmBPprBGMLWZDX3mFN0lJQ= +github.com/kaptinlin/jsonschema v0.7.3 h1:kyIydij76ORiSxmfy0xFYy0cOx8MwG6pyyaSoQshsK4= +github.com/kaptinlin/jsonschema v0.7.3/go.mod h1:Ys6zr+W6/1330FzZEouFrAYImK+AmYt5HQVTHQQXQo8= github.com/kaptinlin/messageformat-go v0.4.18 h1:RBlHVWgZyoxTcUgGWBsl2AcyScq/urqbLZvzgryTmSI= github.com/kaptinlin/messageformat-go v0.4.18/go.mod h1:ntI3154RnqJgr7GaC+vZBnIExl2V3sv9selvRNNEM24= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= @@ -262,8 +264,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= -github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= @@ -423,8 +425,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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -491,10 +493,10 @@ golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0 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.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk= -google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= -google.golang.org/genai v1.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo= -google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= +google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/genai v1.48.0 h1:1vb15G291wAjJJueisMDpUhssljhEdJU2t5qTidrVPs= +google.golang.org/genai v1.48.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go index 29566b1c5a00d00c1254a3f07cdcef71ba55d59e..1a7286e342d245c7e7ac1161111d8c205300018b 100644 --- a/internal/agent/agent_tool.go +++ b/internal/agent/agent_tool.go @@ -4,7 +4,6 @@ import ( "context" _ "embed" "errors" - "fmt" "charm.land/fantasy" @@ -56,50 +55,13 @@ func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) return fantasy.ToolResponse{}, errors.New("agent message id missing from context") } - agentToolSessionID := c.sessions.CreateAgentToolSessionID(agentMessageID, call.ID) - session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, sessionID, "New Agent Session") - if err != nil { - return fantasy.ToolResponse{}, fmt.Errorf("error creating session: %s", err) - } - model := agent.Model() - maxTokens := model.CatwalkCfg.DefaultMaxTokens - if model.ModelCfg.MaxTokens != 0 { - maxTokens = model.ModelCfg.MaxTokens - } - - providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider) - if !ok { - return fantasy.ToolResponse{}, errors.New("model provider not configured") - } - result, err := agent.Run(ctx, SessionAgentCall{ - SessionID: session.ID, - Prompt: params.Prompt, - MaxOutputTokens: maxTokens, - ProviderOptions: getProviderOptions(model, providerCfg), - Temperature: model.ModelCfg.Temperature, - TopP: model.ModelCfg.TopP, - TopK: model.ModelCfg.TopK, - FrequencyPenalty: model.ModelCfg.FrequencyPenalty, - PresencePenalty: model.ModelCfg.PresencePenalty, + return c.runSubAgent(ctx, subAgentParams{ + Agent: agent, + SessionID: sessionID, + AgentMessageID: agentMessageID, + ToolCallID: call.ID, + Prompt: params.Prompt, + SessionTitle: "New Agent Session", }) - if err != nil { - return fantasy.NewTextErrorResponse("error generating response"), nil - } - updatedSession, err := c.sessions.Get(ctx, session.ID) - if err != nil { - return fantasy.ToolResponse{}, fmt.Errorf("error getting session: %s", err) - } - parentSession, err := c.sessions.Get(ctx, sessionID) - if err != nil { - return fantasy.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err) - } - - parentSession.Cost += updatedSession.Cost - - _, err = c.sessions.Save(ctx, parentSession) - if err != nil { - return fantasy.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err) - } - return fantasy.NewTextResponse(result.Response.Content.Text()), nil }), nil } diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 2d52814d446581fca0e7a98368ffaae465aedf2c..0bd942e013b706389fb90352c891a4f2ea014f30 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -184,51 +184,16 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( Tools: fetchTools, }) - agentToolSessionID := c.sessions.CreateAgentToolSessionID(validationResult.AgentMessageID, call.ID) - session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, validationResult.SessionID, "Fetch Analysis") - if err != nil { - return fantasy.ToolResponse{}, fmt.Errorf("error creating session: %s", err) - } - - c.permissions.AutoApproveSession(session.ID) - - // Use small model for web content analysis (faster and cheaper) - maxTokens := small.CatwalkCfg.DefaultMaxTokens - if small.ModelCfg.MaxTokens != 0 { - maxTokens = small.ModelCfg.MaxTokens - } - - result, err := agent.Run(ctx, SessionAgentCall{ - SessionID: session.ID, - Prompt: fullPrompt, - MaxOutputTokens: maxTokens, - ProviderOptions: getProviderOptions(small, smallProviderCfg), - Temperature: small.ModelCfg.Temperature, - TopP: small.ModelCfg.TopP, - TopK: small.ModelCfg.TopK, - FrequencyPenalty: small.ModelCfg.FrequencyPenalty, - PresencePenalty: small.ModelCfg.PresencePenalty, + return c.runSubAgent(ctx, subAgentParams{ + Agent: agent, + SessionID: validationResult.SessionID, + AgentMessageID: validationResult.AgentMessageID, + ToolCallID: call.ID, + Prompt: fullPrompt, + SessionTitle: "Fetch Analysis", + SessionSetup: func(sessionID string) { + c.permissions.AutoApproveSession(sessionID) + }, }) - if err != nil { - return fantasy.NewTextErrorResponse("error generating response"), nil - } - - updatedSession, err := c.sessions.Get(ctx, session.ID) - if err != nil { - return fantasy.ToolResponse{}, fmt.Errorf("error getting session: %s", err) - } - parentSession, err := c.sessions.Get(ctx, validationResult.SessionID) - if err != nil { - return fantasy.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err) - } - - parentSession.Cost += updatedSession.Cost - - _, err = c.sessions.Save(ctx, parentSession) - if err != nil { - return fantasy.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err) - } - - return fantasy.NewTextResponse(result.Response.Content.Text()), nil }), nil } diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 3ab3e68ec046dfb8db9dd0801c4a744c7e148bd2..101b987f2417659828fa68ae68405c1a723322b3 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -182,12 +182,9 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel GeneratedWith: true, } - // Clear skills paths to ensure test reproducibility - user's skills - // 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. + // Clear some fields to avoid issues with VCR cassette matching. + cfg.Options.SkillsPaths = nil + cfg.Options.ContextPaths = nil cfg.LSP = nil systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index b64a90bcc58d3dae71c59175e25eec601c7be083..de34c6f037956e7b0a235c9d9f26aea4842467ff 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -279,12 +279,15 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy. } } case anthropic.Name: - _, hasThink := mergedOptions["thinking"] - if !hasThink && model.ModelCfg.Think { - mergedOptions["thinking"] = map[string]any{ - // TODO: kujtim see if we need to make this dynamic - "budget_tokens": 2000, - } + var ( + _, hasEffort = mergedOptions["effort"] + _, hasThink = mergedOptions["thinking"] + ) + switch { + case !hasEffort && model.ModelCfg.ReasoningEffort != "": + mergedOptions["effort"] = model.ModelCfg.ReasoningEffort + case !hasThink && model.ModelCfg.Think: + mergedOptions["thinking"] = map[string]any{"budget_tokens": 2000} } parsed, err := anthropic.ParseOptions(mergedOptions) if err == nil { @@ -318,9 +321,16 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy. case google.Name: _, hasReasoning := mergedOptions["thinking_config"] if !hasReasoning { - mergedOptions["thinking_config"] = map[string]any{ - "thinking_budget": 2000, - "include_thoughts": true, + if strings.HasPrefix(model.CatwalkCfg.ID, "gemini-2") { + mergedOptions["thinking_config"] = map[string]any{ + "thinking_budget": 2000, + "include_thoughts": true, + } + } else { + mergedOptions["thinking_config"] = map[string]any{ + "thinking_level": model.ModelCfg.ReasoningEffort, + "include_thoughts": true, + } } } parsed, err := google.ParseOptions(mergedOptions) @@ -576,7 +586,7 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map // NOTE: Prevent the SDK from picking up the API key from env. os.Setenv("ANTHROPIC_API_KEY", "") headers["Authorization"] = apiKey - case providerID == string(catwalk.InferenceProviderMiniMax): + case providerID == string(catwalk.InferenceProviderMiniMax) || providerID == string(catwalk.InferenceProviderMiniMaxChina): // NOTE: Prevent the SDK from picking up the API key from env. os.Setenv("ANTHROPIC_API_KEY", "") headers["Authorization"] = "Bearer " + apiKey @@ -946,3 +956,89 @@ func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg con } return nil } + +// subAgentParams holds the parameters for running a sub-agent. +type subAgentParams struct { + Agent SessionAgent + SessionID string + AgentMessageID string + ToolCallID string + Prompt string + SessionTitle string + // SessionSetup is an optional callback invoked after session creation + // but before agent execution, for custom session configuration. + SessionSetup func(sessionID string) +} + +// runSubAgent runs a sub-agent and handles session management and cost accumulation. +// It creates a sub-session, runs the agent with the given prompt, and propagates +// the cost to the parent session. +func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (fantasy.ToolResponse, error) { + // Create sub-session + agentToolSessionID := c.sessions.CreateAgentToolSessionID(params.AgentMessageID, params.ToolCallID) + session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, params.SessionID, params.SessionTitle) + if err != nil { + return fantasy.ToolResponse{}, fmt.Errorf("create session: %w", err) + } + + // Call session setup function if provided + if params.SessionSetup != nil { + params.SessionSetup(session.ID) + } + + // Get model configuration + model := params.Agent.Model() + maxTokens := model.CatwalkCfg.DefaultMaxTokens + if model.ModelCfg.MaxTokens != 0 { + maxTokens = model.ModelCfg.MaxTokens + } + + providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider) + if !ok { + return fantasy.ToolResponse{}, errors.New("model provider not configured") + } + + // Run the agent + result, err := params.Agent.Run(ctx, SessionAgentCall{ + SessionID: session.ID, + Prompt: params.Prompt, + MaxOutputTokens: maxTokens, + ProviderOptions: getProviderOptions(model, providerCfg), + Temperature: model.ModelCfg.Temperature, + TopP: model.ModelCfg.TopP, + TopK: model.ModelCfg.TopK, + FrequencyPenalty: model.ModelCfg.FrequencyPenalty, + PresencePenalty: model.ModelCfg.PresencePenalty, + }) + if err != nil { + return fantasy.NewTextErrorResponse("error generating response"), nil + } + + // Update parent session cost + if err := c.updateParentSessionCost(ctx, session.ID, params.SessionID); err != nil { + return fantasy.ToolResponse{}, err + } + + return fantasy.NewTextResponse(result.Response.Content.Text()), nil +} + +// updateParentSessionCost accumulates the cost from a child session to its parent session. +func (c *coordinator) updateParentSessionCost(ctx context.Context, childSessionID, parentSessionID string) error { + childSession, err := c.sessions.Get(ctx, childSessionID) + if err != nil { + return fmt.Errorf("get child session: %w", err) + } + + parentSession, err := c.sessions.Get(ctx, parentSessionID) + if err != nil { + return fmt.Errorf("get parent session: %w", err) + } + + parentSession.Cost += childSession.Cost + + if _, err := c.sessions.Save(ctx, parentSession); err != nil { + return fmt.Errorf("save parent session: %w", err) + } + + return nil +} diff --git a/internal/agent/coordinator_test.go b/internal/agent/coordinator_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3c270394cba9c1758e4a9029a149027af6bf36c2 --- /dev/null +++ b/internal/agent/coordinator_test.go @@ -0,0 +1,385 @@ +package agent + +import ( + "context" + "errors" + "testing" + + "charm.land/catwalk/pkg/catwalk" + "charm.land/fantasy" + "github.com/charmbracelet/crush/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockSessionAgent is a minimal mock for the SessionAgent interface. +type mockSessionAgent struct { + model Model + runFunc func(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) + cancelled []string +} + +func (m *mockSessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) { + return m.runFunc(ctx, call) +} + +func (m *mockSessionAgent) Model() Model { return m.model } +func (m *mockSessionAgent) SetModels(large, small Model) {} +func (m *mockSessionAgent) SetTools(tools []fantasy.AgentTool) {} +func (m *mockSessionAgent) SetSystemPrompt(systemPrompt string) {} +func (m *mockSessionAgent) Cancel(sessionID string) { + m.cancelled = append(m.cancelled, sessionID) +} +func (m *mockSessionAgent) CancelAll() {} +func (m *mockSessionAgent) IsSessionBusy(sessionID string) bool { return false } +func (m *mockSessionAgent) IsBusy() bool { return false } +func (m *mockSessionAgent) QueuedPrompts(sessionID string) int { return 0 } +func (m *mockSessionAgent) QueuedPromptsList(sessionID string) []string { return nil } +func (m *mockSessionAgent) ClearQueue(sessionID string) {} +func (m *mockSessionAgent) Summarize(context.Context, string, fantasy.ProviderOptions) error { + return nil +} + +// newTestCoordinator creates a minimal coordinator for unit testing runSubAgent. +func newTestCoordinator(t *testing.T, env fakeEnv, providerID string, providerCfg config.ProviderConfig) *coordinator { + cfg, err := config.Init(env.workingDir, "", false) + require.NoError(t, err) + cfg.Providers.Set(providerID, providerCfg) + return &coordinator{ + cfg: cfg, + sessions: env.sessions, + } +} + +// newMockAgent creates a mockSessionAgent with the given provider and run function. +func newMockAgent(providerID string, maxTokens int64, runFunc func(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)) *mockSessionAgent { + return &mockSessionAgent{ + model: Model{ + CatwalkCfg: catwalk.Model{ + DefaultMaxTokens: maxTokens, + }, + ModelCfg: config.SelectedModel{ + Provider: providerID, + }, + }, + runFunc: runFunc, + } +} + +// agentResultWithText creates a minimal AgentResult with the given text response. +func agentResultWithText(text string) *fantasy.AgentResult { + return &fantasy.AgentResult{ + Response: fantasy.Response{ + Content: fantasy.ResponseContent{ + fantasy.TextContent{Text: text}, + }, + }, + } +} + +func TestRunSubAgent(t *testing.T) { + const providerID = "test-provider" + providerCfg := config.ProviderConfig{ID: providerID} + + t.Run("happy path", func(t *testing.T) { + env := testEnv(t) + coord := newTestCoordinator(t, env, providerID, providerCfg) + + parentSession, err := env.sessions.Create(t.Context(), "Parent") + require.NoError(t, err) + + agent := newMockAgent(providerID, 4096, func(_ context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) { + assert.Equal(t, "do something", call.Prompt) + assert.Equal(t, int64(4096), call.MaxOutputTokens) + return agentResultWithText("done"), nil + }) + + resp, err := coord.runSubAgent(t.Context(), subAgentParams{ + Agent: agent, + SessionID: parentSession.ID, + AgentMessageID: "msg-1", + ToolCallID: "call-1", + Prompt: "do something", + SessionTitle: "Test Session", + }) + require.NoError(t, err) + assert.Equal(t, "done", resp.Content) + assert.False(t, resp.IsError) + }) + + t.Run("ModelCfg.MaxTokens overrides default", func(t *testing.T) { + env := testEnv(t) + coord := newTestCoordinator(t, env, providerID, providerCfg) + + parentSession, err := env.sessions.Create(t.Context(), "Parent") + require.NoError(t, err) + + agent := &mockSessionAgent{ + model: Model{ + CatwalkCfg: catwalk.Model{ + DefaultMaxTokens: 4096, + }, + ModelCfg: config.SelectedModel{ + Provider: providerID, + MaxTokens: 8192, + }, + }, + runFunc: func(_ context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) { + assert.Equal(t, int64(8192), call.MaxOutputTokens) + return agentResultWithText("ok"), nil + }, + } + + resp, err := coord.runSubAgent(t.Context(), subAgentParams{ + Agent: agent, + SessionID: parentSession.ID, + AgentMessageID: "msg-1", + ToolCallID: "call-1", + Prompt: "test", + SessionTitle: "Test", + }) + require.NoError(t, err) + assert.Equal(t, "ok", resp.Content) + }) + + t.Run("session creation failure with canceled context", func(t *testing.T) { + env := testEnv(t) + coord := newTestCoordinator(t, env, providerID, providerCfg) + + parentSession, err := env.sessions.Create(t.Context(), "Parent") + require.NoError(t, err) + + agent := newMockAgent(providerID, 4096, nil) + + // Use a canceled context to trigger CreateTaskSession failure. + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err = coord.runSubAgent(ctx, subAgentParams{ + Agent: agent, + SessionID: parentSession.ID, + AgentMessageID: "msg-1", + ToolCallID: "call-1", + Prompt: "test", + SessionTitle: "Test", + }) + require.Error(t, err) + }) + + t.Run("provider not configured", func(t *testing.T) { + env := testEnv(t) + coord := newTestCoordinator(t, env, providerID, providerCfg) + + parentSession, err := env.sessions.Create(t.Context(), "Parent") + require.NoError(t, err) + + // Agent references a provider that doesn't exist in config. + agent := newMockAgent("unknown-provider", 4096, nil) + + _, err = coord.runSubAgent(t.Context(), subAgentParams{ + Agent: agent, + SessionID: parentSession.ID, + AgentMessageID: "msg-1", + ToolCallID: "call-1", + Prompt: "test", + SessionTitle: "Test", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "model provider not configured") + }) + + t.Run("agent run error returns error response", func(t *testing.T) { + env := testEnv(t) + coord := newTestCoordinator(t, env, providerID, providerCfg) + + parentSession, err := env.sessions.Create(t.Context(), "Parent") + require.NoError(t, err) + + agent := newMockAgent(providerID, 4096, func(_ context.Context, _ SessionAgentCall) (*fantasy.AgentResult, error) { + return nil, errors.New("agent exploded") + }) + + resp, err := coord.runSubAgent(t.Context(), subAgentParams{ + Agent: agent, + SessionID: parentSession.ID, + AgentMessageID: "msg-1", + ToolCallID: "call-1", + Prompt: "test", + SessionTitle: "Test", + }) + // runSubAgent returns (errorResponse, nil) when agent.Run fails — not a Go error. + require.NoError(t, err) + assert.True(t, resp.IsError) + assert.Equal(t, "error generating response", resp.Content) + }) + + t.Run("session setup callback is invoked", func(t *testing.T) { + env := testEnv(t) + coord := newTestCoordinator(t, env, providerID, providerCfg) + + parentSession, err := env.sessions.Create(t.Context(), "Parent") + require.NoError(t, err) + + var setupCalledWith string + agent := newMockAgent(providerID, 4096, func(_ context.Context, _ SessionAgentCall) (*fantasy.AgentResult, error) { + return agentResultWithText("ok"), nil + }) + + _, err = coord.runSubAgent(t.Context(), subAgentParams{ + Agent: agent, + SessionID: parentSession.ID, + AgentMessageID: "msg-1", + ToolCallID: "call-1", + Prompt: "test", + SessionTitle: "Test", + SessionSetup: func(sessionID string) { + setupCalledWith = sessionID + }, + }) + require.NoError(t, err) + assert.NotEmpty(t, setupCalledWith, "SessionSetup should have been called") + }) + + t.Run("cost propagation to parent session", func(t *testing.T) { + env := testEnv(t) + coord := newTestCoordinator(t, env, providerID, providerCfg) + + parentSession, err := env.sessions.Create(t.Context(), "Parent") + require.NoError(t, err) + + agent := newMockAgent(providerID, 4096, func(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) { + // Simulate the agent incurring cost by updating the child session. + childSession, err := env.sessions.Get(ctx, call.SessionID) + if err != nil { + return nil, err + } + childSession.Cost = 0.05 + _, err = env.sessions.Save(ctx, childSession) + if err != nil { + return nil, err + } + return agentResultWithText("ok"), nil + }) + + _, err = coord.runSubAgent(t.Context(), subAgentParams{ + Agent: agent, + SessionID: parentSession.ID, + AgentMessageID: "msg-1", + ToolCallID: "call-1", + Prompt: "test", + SessionTitle: "Test", + }) + require.NoError(t, err) + + updated, err := env.sessions.Get(t.Context(), parentSession.ID) + require.NoError(t, err) + assert.InDelta(t, 0.05, updated.Cost, 1e-9) + }) +} + +func TestUpdateParentSessionCost(t *testing.T) { + t.Run("accumulates cost correctly", func(t *testing.T) { + env := testEnv(t) + cfg, err := config.Init(env.workingDir, "", false) + require.NoError(t, err) + coord := &coordinator{cfg: cfg, sessions: env.sessions} + + parent, err := env.sessions.Create(t.Context(), "Parent") + require.NoError(t, err) + + child, err := env.sessions.CreateTaskSession(t.Context(), "tool-1", parent.ID, "Child") + require.NoError(t, err) + + // Set child cost. + child.Cost = 0.10 + _, err = env.sessions.Save(t.Context(), child) + require.NoError(t, err) + + err = coord.updateParentSessionCost(t.Context(), child.ID, parent.ID) + require.NoError(t, err) + + updated, err := env.sessions.Get(t.Context(), parent.ID) + require.NoError(t, err) + assert.InDelta(t, 0.10, updated.Cost, 1e-9) + }) + + t.Run("accumulates multiple child costs", func(t *testing.T) { + env := testEnv(t) + cfg, err := config.Init(env.workingDir, "", false) + require.NoError(t, err) + coord := &coordinator{cfg: cfg, sessions: env.sessions} + + parent, err := env.sessions.Create(t.Context(), "Parent") + require.NoError(t, err) + + child1, err := env.sessions.CreateTaskSession(t.Context(), "tool-1", parent.ID, "Child1") + require.NoError(t, err) + child1.Cost = 0.05 + _, err = env.sessions.Save(t.Context(), child1) + require.NoError(t, err) + + child2, err := env.sessions.CreateTaskSession(t.Context(), "tool-2", parent.ID, "Child2") + require.NoError(t, err) + child2.Cost = 0.03 + _, err = env.sessions.Save(t.Context(), child2) + require.NoError(t, err) + + err = coord.updateParentSessionCost(t.Context(), child1.ID, parent.ID) + require.NoError(t, err) + err = coord.updateParentSessionCost(t.Context(), child2.ID, parent.ID) + require.NoError(t, err) + + updated, err := env.sessions.Get(t.Context(), parent.ID) + require.NoError(t, err) + assert.InDelta(t, 0.08, updated.Cost, 1e-9) + }) + + t.Run("child session not found", func(t *testing.T) { + env := testEnv(t) + cfg, err := config.Init(env.workingDir, "", false) + require.NoError(t, err) + coord := &coordinator{cfg: cfg, sessions: env.sessions} + + parent, err := env.sessions.Create(t.Context(), "Parent") + require.NoError(t, err) + + err = coord.updateParentSessionCost(t.Context(), "non-existent", parent.ID) + require.Error(t, err) + assert.Contains(t, err.Error(), "get child session") + }) + + t.Run("parent session not found", func(t *testing.T) { + env := testEnv(t) + cfg, err := config.Init(env.workingDir, "", false) + require.NoError(t, err) + coord := &coordinator{cfg: cfg, sessions: env.sessions} + + parent, err := env.sessions.Create(t.Context(), "Parent") + require.NoError(t, err) + child, err := env.sessions.CreateTaskSession(t.Context(), "tool-1", parent.ID, "Child") + require.NoError(t, err) + + err = coord.updateParentSessionCost(t.Context(), child.ID, "non-existent") + require.Error(t, err) + assert.Contains(t, err.Error(), "get parent session") + }) + + t.Run("zero cost handled correctly", func(t *testing.T) { + env := testEnv(t) + cfg, err := config.Init(env.workingDir, "", false) + require.NoError(t, err) + coord := &coordinator{cfg: cfg, sessions: env.sessions} + + parent, err := env.sessions.Create(t.Context(), "Parent") + require.NoError(t, err) + child, err := env.sessions.CreateTaskSession(t.Context(), "tool-1", parent.ID, "Child") + require.NoError(t, err) + + err = coord.updateParentSessionCost(t.Context(), child.ID, parent.ID) + require.NoError(t, err) + + updated, err := env.sessions.Get(t.Context(), parent.ID) + require.NoError(t, err) + assert.InDelta(t, 0.0, updated.Cost, 1e-9) + }) +} diff --git a/internal/agent/hyper/provider.json b/internal/agent/hyper/provider.json index 94bb0305e08c1cf869d237136193551aace9670e..05f1a7d15762919adc462fbed9d9b2dd492ea26a 100644 --- a/internal/agent/hyper/provider.json +++ b/internal/agent/hyper/provider.json @@ -1 +1 @@ -{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://hyper.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-opus-4-5","default_small_model_id":"claude-haiku-4-5","models":[{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-6","name":"Claude Opus 4.6","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":126000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-3-flash","name":"Gemini 3 Flash","cost_per_1m_in":0.5,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.049999999999999996,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3-pro-preview","name":"Gemini 3 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"glm-4.7","name":"GLM-4.7","cost_per_1m_in":0.43,"cost_per_1m_out":1.75,"cost_per_1m_in_cached":0.08,"cost_per_1m_out_cached":0,"context_window":202752,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-4.7-flashx","name":"GLM-4.7 Flash","cost_per_1m_in":0.06,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0.01,"cost_per_1m_out_cached":0,"context_window":200000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-5","name":"GLM-5","cost_per_1m_in":1,"cost_per_1m_out":3.1999999999999997,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":202800,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2-codex","name":"GPT-5.2 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-non-reasoning","name":"Grok 4.1 Fast Non Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-reasoning","name":"Grok 4.1 Fast Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"grok-code-fast-1","name":"Grok Code Fast","cost_per_1m_in":0.2,"cost_per_1m_out":1.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.02,"context_window":256000,"default_max_tokens":20000,"can_reason":true,"supports_attachments":false,"options":{}},{"id":"kimi-k2-0905","name":"Kimi K2","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"kimi-k2.5","name":"Kimi K2.5","cost_per_1m_in":0.5,"cost_per_1m_out":2.8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}}]} \ No newline at end of file +{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://hyper.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-opus-4-5","default_small_model_id":"claude-haiku-4-5","models":[{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-6","name":"Claude Opus 4.6","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":126000,"can_reason":true,"reasoning_levels":["low","medium","high","max"],"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-3-flash","name":"Gemini 3 Flash","cost_per_1m_in":0.5,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.049999999999999996,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3-pro-preview","name":"Gemini 3 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3.1-pro-preview","name":"Gemini 3.1 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"glm-4.7","name":"GLM-4.7","cost_per_1m_in":0.43,"cost_per_1m_out":1.75,"cost_per_1m_in_cached":0.08,"cost_per_1m_out_cached":0,"context_window":202752,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-4.7-flashx","name":"GLM-4.7 Flash","cost_per_1m_in":0.06,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0.01,"cost_per_1m_out_cached":0,"context_window":200000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-5","name":"GLM-5","cost_per_1m_in":1,"cost_per_1m_out":3.1999999999999997,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":202800,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2-codex","name":"GPT-5.2 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.3-codex","name":"GPT-5.3 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-non-reasoning","name":"Grok 4.1 Fast Non Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-reasoning","name":"Grok 4.1 Fast Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"grok-code-fast-1","name":"Grok Code Fast","cost_per_1m_in":0.2,"cost_per_1m_out":1.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.02,"context_window":256000,"default_max_tokens":20000,"can_reason":true,"supports_attachments":false,"options":{}},{"id":"kimi-k2-0905","name":"Kimi K2","cost_per_1m_in":0.548,"cost_per_1m_out":2.192,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":262144,"default_max_tokens":26214,"can_reason":false,"supports_attachments":false,"options":{}},{"id":"kimi-k2.5","name":"Kimi K2.5","cost_per_1m_in":0.5,"cost_per_1m_out":2.8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}}]} \ No newline at end of file diff --git a/internal/agent/tools/context_test.go b/internal/agent/tools/context_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ee57313f36ae38df1412656118fbd1bbbf707d0b --- /dev/null +++ b/internal/agent/tools/context_test.go @@ -0,0 +1,233 @@ +package tools + +import ( + "context" + "testing" +) + +// Test-specific context key types to avoid collisions +type ( + testStringKey string + testBoolKey string + testIntKey string +) + +const ( + testKey testStringKey = "testKey" + missingKey testStringKey = "missingKey" + boolTestKey testBoolKey = "boolKey" + intTestKey testIntKey = "intKey" +) + +func TestGetContextValue(t *testing.T) { + tests := []struct { + name string + setup func(ctx context.Context) context.Context + key any + defaultValue any + want any + }{ + { + name: "returns string value", + setup: func(ctx context.Context) context.Context { + return context.WithValue(ctx, testKey, "testValue") + }, + key: testKey, + defaultValue: "", + want: "testValue", + }, + { + name: "returns default when key not found", + setup: func(ctx context.Context) context.Context { + return ctx + }, + key: missingKey, + defaultValue: "default", + want: "default", + }, + { + name: "returns default when type mismatch", + setup: func(ctx context.Context) context.Context { + return context.WithValue(ctx, testKey, 123) // int, not string + }, + key: testKey, + defaultValue: "default", + want: "default", + }, + { + name: "returns bool value", + setup: func(ctx context.Context) context.Context { + return context.WithValue(ctx, boolTestKey, true) + }, + key: boolTestKey, + defaultValue: false, + want: true, + }, + { + name: "returns int value", + setup: func(ctx context.Context) context.Context { + return context.WithValue(ctx, intTestKey, 42) + }, + key: intTestKey, + defaultValue: 0, + want: 42, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := tt.setup(context.Background()) + + var got any + switch tt.defaultValue.(type) { + case string: + got = getContextValue(ctx, tt.key, tt.defaultValue.(string)) + case bool: + got = getContextValue(ctx, tt.key, tt.defaultValue.(bool)) + case int: + got = getContextValue(ctx, tt.key, tt.defaultValue.(int)) + } + + if got != tt.want { + t.Errorf("getContextValue() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetSessionFromContext(t *testing.T) { + tests := []struct { + name string + ctx context.Context + want string + }{ + { + name: "returns session ID when present", + ctx: context.WithValue(context.Background(), SessionIDContextKey, "session-123"), + want: "session-123", + }, + { + name: "returns empty string when not present", + ctx: context.Background(), + want: "", + }, + { + name: "returns empty string when wrong type", + ctx: context.WithValue(context.Background(), SessionIDContextKey, 123), + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetSessionFromContext(tt.ctx) + if got != tt.want { + t.Errorf("GetSessionFromContext() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetMessageFromContext(t *testing.T) { + tests := []struct { + name string + ctx context.Context + want string + }{ + { + name: "returns message ID when present", + ctx: context.WithValue(context.Background(), MessageIDContextKey, "msg-456"), + want: "msg-456", + }, + { + name: "returns empty string when not present", + ctx: context.Background(), + want: "", + }, + { + name: "returns empty string when wrong type", + ctx: context.WithValue(context.Background(), MessageIDContextKey, 456), + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetMessageFromContext(tt.ctx) + if got != tt.want { + t.Errorf("GetMessageFromContext() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetSupportsImagesFromContext(t *testing.T) { + tests := []struct { + name string + ctx context.Context + want bool + }{ + { + name: "returns true when present and true", + ctx: context.WithValue(context.Background(), SupportsImagesContextKey, true), + want: true, + }, + { + name: "returns false when present and false", + ctx: context.WithValue(context.Background(), SupportsImagesContextKey, false), + want: false, + }, + { + name: "returns false when not present", + ctx: context.Background(), + want: false, + }, + { + name: "returns false when wrong type", + ctx: context.WithValue(context.Background(), SupportsImagesContextKey, "true"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetSupportsImagesFromContext(tt.ctx) + if got != tt.want { + t.Errorf("GetSupportsImagesFromContext() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetModelNameFromContext(t *testing.T) { + tests := []struct { + name string + ctx context.Context + want string + }{ + { + name: "returns model name when present", + ctx: context.WithValue(context.Background(), ModelNameContextKey, "claude-opus-4"), + want: "claude-opus-4", + }, + { + name: "returns empty string when not present", + ctx: context.Background(), + want: "", + }, + { + name: "returns empty string when wrong type", + ctx: context.WithValue(context.Background(), ModelNameContextKey, 789), + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetModelNameFromContext(tt.ctx) + if got != tt.want { + t.Errorf("GetModelNameFromContext() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index 41a1b8abfa8e54c32de783cd2bf1da11f3bdf264..79b67be95d3312af47a7d5bc1765cc320a9894d1 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -7,6 +7,7 @@ import ( "log/slog" "sort" "strings" + "sync" "time" "charm.land/fantasy" @@ -37,16 +38,61 @@ func NewDiagnosticsTool(lspManager *lsp.Manager) fantasy.AgentTool { }) } -func notifyLSPs( +// openInLSPs ensures LSP servers are running and aware of the file, but does +// not notify changes or wait for fresh diagnostics. Use this for read-only +// operations like view where the file content hasn't changed. +func openInLSPs( ctx context.Context, manager *lsp.Manager, filepath string, ) { - if filepath == "" { + if filepath == "" || manager == nil { return } - if manager == nil { + manager.Start(ctx, filepath) + + for client := range manager.Clients().Seq() { + if !client.HandlesFile(filepath) { + continue + } + _ = client.OpenFileOnDemand(ctx, filepath) + } +} + +// waitForLSPDiagnostics waits briefly for diagnostics publication after a file +// has been opened. Intended for read-only situations where viewing up-to-date +// files matters but latency should remain low (i.e. when using the view tool). +func waitForLSPDiagnostics( + ctx context.Context, + manager *lsp.Manager, + filepath string, + timeout time.Duration, +) { + if filepath == "" || manager == nil || timeout <= 0 { + return + } + + var wg sync.WaitGroup + for client := range manager.Clients().Seq() { + if !client.HandlesFile(filepath) { + continue + } + wg.Go(func() { + client.WaitForDiagnostics(ctx, timeout) + }) + } + wg.Wait() +} + +// notifyLSPs notifies LSP servers that a file has changed and waits for +// updated diagnostics. Use this after edit/multiedit operations. +func notifyLSPs( + ctx context.Context, + manager *lsp.Manager, + filepath string, +) { + if filepath == "" || manager == nil { return } diff --git a/internal/agent/tools/job_output.go b/internal/agent/tools/job_output.go index cd0345e03e769e27012e045c36768f52a1ed069a..092fe3fea13cd7fe99004e1927167813f0ca6d87 100644 --- a/internal/agent/tools/job_output.go +++ b/internal/agent/tools/job_output.go @@ -19,6 +19,7 @@ var jobOutputDescription []byte type JobOutputParams struct { ShellID string `json:"shell_id" description:"The ID of the background shell to retrieve output from"` + Wait bool `json:"wait" description:"If true, block until the background shell completes before returning output"` } type JobOutputResponseMetadata struct { @@ -44,6 +45,10 @@ func NewJobOutputTool() fantasy.AgentTool { return fantasy.NewTextErrorResponse(fmt.Sprintf("background shell not found: %s", params.ShellID)), nil } + if params.Wait { + bgShell.WaitContext(ctx) + } + stdout, stderr, done, err := bgShell.GetOutput() var outputParts []string diff --git a/internal/agent/tools/job_output.md b/internal/agent/tools/job_output.md index 460496ccb4a04a36606b5a25252187feeb2c8aae..3a0162525289dac5530846d95268f5d4bda1b8dd 100644 --- a/internal/agent/tools/job_output.md +++ b/internal/agent/tools/job_output.md @@ -4,16 +4,19 @@ Retrieves the current output from a background shell. - Provide the shell ID returned from a background bash execution - Returns the current stdout and stderr output - Indicates whether the shell has completed execution +- Set wait=true to block until the shell completes or the request context is done - View output from running background processes - Check if background process has completed - Get cumulative output from process start +- Optionally wait for process completion (returns early on context cancel) - Use this to monitor long-running processes - Check the 'done' status to see if process completed - Can be called multiple times to view incremental output +- Use wait=true when you need the final output and exit status (or current output if the request cancels) diff --git a/internal/agent/tools/mcp/resources.go b/internal/agent/tools/mcp/resources.go index da661817c24f8fc1324f509d1834e9d03d5fd2c9..8e2bcc796b28c698481dd90b0c70511273f7c98d 100644 --- a/internal/agent/tools/mcp/resources.go +++ b/internal/agent/tools/mcp/resources.go @@ -83,7 +83,7 @@ func getResources(ctx context.Context, c *ClientSession) ([]*Resource, error) { } result, err := c.ListResources(ctx, &mcp.ListResourcesParams{}) if err != nil { - // Handle "Method not found" errors from MCP servers that don't support resources/list + // Handle "Method not found" errors from MCP servers that don't support resources/list. if isMethodNotFoundError(err) { slog.Warn("MCP server does not support resources/list", "error", err) return nil, nil diff --git a/internal/agent/tools/references.go b/internal/agent/tools/references.go index c544886b9de3e60ef6932cbc2932fc0a0ab639f0..aef683eb9dfee44b92e96cd3cb88b8654eb03b2a 100644 --- a/internal/agent/tools/references.go +++ b/internal/agent/tools/references.go @@ -71,7 +71,11 @@ func NewReferencesTool(lspManager *lsp.Manager) fantasy.AgentTool { continue } allLocations = append(allLocations, locations...) - // XXX: should we break here or look for all results? + // Once we have results, we're done - LSP returns all references + // for the symbol, not just from this file. + if len(locations) > 0 { + break + } } if len(allLocations) > 0 { @@ -172,15 +176,15 @@ func formatReferences(locations []protocol.Location) string { sort.Strings(files) var output strings.Builder - output.WriteString(fmt.Sprintf("Found %d reference(s) in %d file(s):\n\n", len(locations), len(files))) + fmt.Fprintf(&output, "Found %d reference(s) in %d file(s):\n\n", len(locations), len(files)) for _, file := range files { refs := fileRefs[file] - output.WriteString(fmt.Sprintf("%s (%d reference(s)):\n", file, len(refs))) + fmt.Fprintf(&output, "%s (%d reference(s)):\n", file, len(refs)) for _, ref := range refs { line := ref.Range.Start.Line + 1 char := ref.Range.Start.Character + 1 - output.WriteString(fmt.Sprintf(" Line %d, Column %d\n", line, char)) + fmt.Fprintf(&output, " Line %d, Column %d\n", line, char) } output.WriteString("\n") } diff --git a/internal/agent/tools/search.go b/internal/agent/tools/search.go index 8d21162001e129f2f614e56b1288bad89904f4c0..c27428f56b328052f19e227e5cbb233ed0f88a49 100644 --- a/internal/agent/tools/search.go +++ b/internal/agent/tools/search.go @@ -191,11 +191,11 @@ func formatSearchResults(results []SearchResult) string { } var sb strings.Builder - sb.WriteString(fmt.Sprintf("Found %d search results:\n\n", len(results))) + fmt.Fprintf(&sb, "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)) + fmt.Fprintf(&sb, "%d. %s\n", result.Position, result.Title) + fmt.Fprintf(&sb, " URL: %s\n", result.Link) + fmt.Fprintf(&sb, " Summary: %s\n\n", result.Snippet) } return sb.String() } diff --git a/internal/agent/tools/sourcegraph.go b/internal/agent/tools/sourcegraph.go index 72ecf2d6edb924594bc0c8700d88b6d8db256b50..e6d1014daf80aa062ebccead936b5f39bf2c5632 100644 --- a/internal/agent/tools/sourcegraph.go +++ b/internal/agent/tools/sourcegraph.go @@ -145,7 +145,7 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string, for _, err := range errors { if errMap, ok := err.(map[string]any); ok { if message, ok := errMap["message"].(string); ok { - buffer.WriteString(fmt.Sprintf("- %s\n", message)) + fmt.Fprintf(&buffer, "- %s\n", message) } } } @@ -172,7 +172,7 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string, limitHit, _ := searchResults["limitHit"].(bool) buffer.WriteString("# Sourcegraph Search Results\n\n") - buffer.WriteString(fmt.Sprintf("Found %d matches across %d results\n", int(matchCount), int(resultCount))) + fmt.Fprintf(&buffer, "Found %d matches across %d results\n", int(matchCount), int(resultCount)) if limitHit { buffer.WriteString("(Result limit reached, try a more specific query)\n") @@ -215,10 +215,10 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string, fileURL, _ := file["url"].(string) fileContent, _ := file["content"].(string) - buffer.WriteString(fmt.Sprintf("## Result %d: %s/%s\n\n", i+1, repoName, filePath)) + fmt.Fprintf(&buffer, "## Result %d: %s/%s\n\n", i+1, repoName, filePath) if fileURL != "" { - buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL)) + fmt.Fprintf(&buffer, "URL: %s\n\n", fileURL) } if len(lineMatches) > 0 { @@ -240,24 +240,24 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string, for j := startLine - 1; j < int(lineNumber)-1 && j < len(lines); j++ { if j >= 0 { - buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j])) + fmt.Fprintf(&buffer, "%d| %s\n", j+1, lines[j]) } } - buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview)) + fmt.Fprintf(&buffer, "%d| %s\n", int(lineNumber), preview) endLine := int(lineNumber) + contextWindow for j := int(lineNumber); j < endLine && j < len(lines); j++ { if j < len(lines) { - buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j])) + fmt.Fprintf(&buffer, "%d| %s\n", j+1, lines[j]) } } buffer.WriteString("```\n\n") } else { buffer.WriteString("```\n") - buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview)) + fmt.Fprintf(&buffer, "%d| %s\n", int(lineNumber), preview) buffer.WriteString("```\n\n") } } diff --git a/internal/agent/tools/tools.go b/internal/agent/tools/tools.go index 7d03d0e22714205f0883de5b15960a104f9f6b98..50a2f7af24f9b1bc920fb88bc9a0df1123db9ebc 100644 --- a/internal/agent/tools/tools.go +++ b/internal/agent/tools/tools.go @@ -22,53 +22,35 @@ const ( ModelNameContextKey modelNameKey = "model_name" ) -// GetSessionFromContext retrieves the session ID from the context. -func GetSessionFromContext(ctx context.Context) string { - sessionID := ctx.Value(SessionIDContextKey) - if sessionID == nil { - return "" +// getContextValue is a generic helper that retrieves a typed value from context. +// If the value is not found or has the wrong type, it returns the default value. +func getContextValue[T any](ctx context.Context, key any, defaultValue T) T { + value := ctx.Value(key) + if value == nil { + return defaultValue } - s, ok := sessionID.(string) - if !ok { - return "" + if typedValue, ok := value.(T); ok { + return typedValue } - return s + return defaultValue +} + +// GetSessionFromContext retrieves the session ID from the context. +func GetSessionFromContext(ctx context.Context) string { + return getContextValue(ctx, SessionIDContextKey, "") } // GetMessageFromContext retrieves the message ID from the context. func GetMessageFromContext(ctx context.Context) string { - messageID := ctx.Value(MessageIDContextKey) - if messageID == nil { - return "" - } - s, ok := messageID.(string) - if !ok { - return "" - } - return s + return getContextValue(ctx, MessageIDContextKey, "") } // GetSupportsImagesFromContext retrieves whether the model supports images from the context. func GetSupportsImagesFromContext(ctx context.Context) bool { - supportsImages := ctx.Value(SupportsImagesContextKey) - if supportsImages == nil { - return false - } - if supports, ok := supportsImages.(bool); ok { - return supports - } - return false + return getContextValue(ctx, SupportsImagesContextKey, false) } // GetModelNameFromContext retrieves the model name from the context. func GetModelNameFromContext(ctx context.Context) string { - modelName := ctx.Value(ModelNameContextKey) - if modelName == nil { - return "" - } - s, ok := modelName.(string) - if !ok { - return "" - } - return s + return getContextValue(ctx, ModelNameContextKey, "") } diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 0e56a4f6866d018efabfa952b7a10dc97507656f..9b856fd1fad961a2aabf491c0f8aac6914965e2e 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "time" "unicode/utf8" "charm.land/fantasy" @@ -97,7 +98,7 @@ func NewViewTool( // Request permission for files outside working directory, unless it's a skill file. if isOutsideWorkDir && !isSkillFile { - granted, err := permissions.Request(ctx, + granted, permReqErr := permissions.Request(ctx, permission.CreatePermissionRequest{ SessionID: sessionID, Path: absFilePath, @@ -108,8 +109,8 @@ func NewViewTool( Params: ViewPermissionsParams(params), }, ) - if err != nil { - return fantasy.ToolResponse{}, err + if permReqErr != nil { + return fantasy.ToolResponse{}, permReqErr } if !granted { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied @@ -175,9 +176,9 @@ func NewViewTool( return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil } - imageData, err := os.ReadFile(filePath) - if err != nil { - return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", err) + imageData, readErr := os.ReadFile(filePath) + if readErr != nil { + return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", readErr) } encoded := base64.StdEncoding.EncodeToString(imageData) @@ -185,22 +186,20 @@ func NewViewTool( } // Read the file content - content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit) + content, hasMore, err := readTextFile(filePath, params.Offset, params.Limit) if err != nil { return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err) } - isValidUt8 := utf8.ValidString(content) - if !isValidUt8 { + if !utf8.ValidString(content) { return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil } - notifyLSPs(ctx, lspManager, filePath) + openInLSPs(ctx, lspManager, filePath) + waitForLSPDiagnostics(ctx, lspManager, filePath, 300*time.Millisecond) output := "\n" - // Format the output with line numbers output += addLineNumbers(content, params.Offset+1) - // Add a note if the content was truncated - if lineCount > params.Offset+len(strings.Split(content, "\n")) { + if hasMore { output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)", params.Offset+len(strings.Split(content, "\n"))) } @@ -252,38 +251,28 @@ func addLineNumbers(content string, startLine int) string { return strings.Join(result, "\n") } -func readTextFile(filePath string, offset, limit int) (string, int, error) { +func readTextFile(filePath string, offset, limit int) (string, bool, error) { file, err := os.Open(filePath) if err != nil { - return "", 0, err + return "", false, err } defer file.Close() - lineCount := 0 - scanner := NewLineScanner(file) if offset > 0 { - for lineCount < offset && scanner.Scan() { - lineCount++ + skipped := 0 + for skipped < offset && scanner.Scan() { + skipped++ } if err = scanner.Err(); err != nil { - return "", 0, err - } - } - - if offset == 0 { - _, err = file.Seek(0, io.SeekStart) - if err != nil { - return "", 0, err + return "", false, err } } - // Pre-allocate slice with expected capacity + // Pre-allocate slice with expected capacity. lines := make([]string, 0, limit) - lineCount = offset - for scanner.Scan() && len(lines) < limit { - lineCount++ + for len(lines) < limit && scanner.Scan() { lineText := scanner.Text() if len(lineText) > MaxLineLength { lineText = lineText[:MaxLineLength] + "..." @@ -291,16 +280,14 @@ func readTextFile(filePath string, offset, limit int) (string, int, error) { lines = append(lines, lineText) } - // Continue scanning to get total line count - for scanner.Scan() { - lineCount++ - } + // Peek one more line only when we filled the limit. + hasMore := len(lines) == limit && scanner.Scan() if err := scanner.Err(); err != nil { - return "", 0, err + return "", false, err } - return strings.Join(lines, "\n"), lineCount, nil + return strings.Join(lines, "\n"), hasMore, nil } func getImageMimeType(filePath string) (bool, string) { diff --git a/internal/agent/tools/view_test.go b/internal/agent/tools/view_test.go new file mode 100644 index 0000000000000000000000000000000000000000..18b61f4e5012b4a685405fa1d1e31b90f6c790bc --- /dev/null +++ b/internal/agent/tools/view_test.go @@ -0,0 +1,87 @@ +package tools + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReadTextFileBoundaryCases(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "sample.txt") + + var allLines []string + for i := range 5 { + allLines = append(allLines, fmt.Sprintf("line %d", i+1)) + } + require.NoError(t, os.WriteFile(filePath, []byte(strings.Join(allLines, "\n")), 0o644)) + + tests := []struct { + name string + offset int + limit int + wantContent string + wantHasMore bool + }{ + { + name: "exactly limit lines remaining", + offset: 0, + limit: 5, + wantContent: "line 1\nline 2\nline 3\nline 4\nline 5", + wantHasMore: false, + }, + { + name: "limit plus one line remaining", + offset: 0, + limit: 4, + wantContent: "line 1\nline 2\nline 3\nline 4", + wantHasMore: true, + }, + { + name: "offset at last line", + offset: 4, + limit: 3, + wantContent: "line 5", + wantHasMore: false, + }, + { + name: "offset beyond eof", + offset: 10, + limit: 3, + wantContent: "", + wantHasMore: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotContent, gotHasMore, err := readTextFile(filePath, tt.offset, tt.limit) + require.NoError(t, err) + require.Equal(t, tt.wantContent, gotContent) + require.Equal(t, tt.wantHasMore, gotHasMore) + }) + } +} + +func TestReadTextFileTruncatesLongLines(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "longline.txt") + + longLine := strings.Repeat("a", MaxLineLength+10) + require.NoError(t, os.WriteFile(filePath, []byte(longLine), 0o644)) + + content, hasMore, err := readTextFile(filePath, 0, 1) + require.NoError(t, err) + require.False(t, hasMore) + require.Equal(t, strings.Repeat("a", MaxLineLength)+"...", content) +} diff --git a/internal/agent/tools/web_fetch.go b/internal/agent/tools/web_fetch.go index 91c326a7b8671d4cdff9b7b04329371075c5dc94..3f9849724ff9a5deef9131482c2527a55b550098 100644 --- a/internal/agent/tools/web_fetch.go +++ b/internal/agent/tools/web_fetch.go @@ -60,11 +60,11 @@ func NewWebFetchTool(workingDir string, client *http.Client) fantasy.AgentTool { return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to close temporary file: %s", err)), nil } - result.WriteString(fmt.Sprintf("Fetched content from %s (large page)\n\n", params.URL)) - result.WriteString(fmt.Sprintf("Content saved to: %s\n\n", tempFilePath)) + fmt.Fprintf(&result, "Fetched content from %s (large page)\n\n", params.URL) + fmt.Fprintf(&result, "Content saved to: %s\n\n", tempFilePath) result.WriteString("Use the view and grep tools to analyze this file.") } else { - result.WriteString(fmt.Sprintf("Fetched content from %s:\n\n", params.URL)) + fmt.Fprintf(&result, "Fetched content from %s:\n\n", params.URL) result.WriteString(content) } diff --git a/internal/cmd/stats.go b/internal/cmd/stats.go index 5dc971d1229350f35f93d5cf772239fa83e9206e..8831c2a647a283bfe6d6edff15c5eff4dafb3377 100644 --- a/internal/cmd/stats.go +++ b/internal/cmd/stats.go @@ -16,6 +16,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/event" "github.com/pkg/browser" "github.com/spf13/cobra" ) @@ -120,6 +121,8 @@ type HourDayHeatmapPt struct { } func runStats(cmd *cobra.Command, _ []string) error { + event.StatsViewed() + dataDir, _ := cmd.Flags().GetString("data-dir") ctx := cmd.Context() diff --git a/internal/config/config.go b/internal/config/config.go index 753151509315545dfbed9bd74c1455785313c8aa..c4ef08760ca329d5d0b5644985552e6013d9edd2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -792,7 +792,7 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error { ) switch providerID { - case catwalk.InferenceProviderMiniMax: + case catwalk.InferenceProviderMiniMax, catwalk.InferenceProviderMiniMaxChina: // NOTE: MiniMax has no good endpoint we can use to validate the API key. // Let's at least check the pattern. if !strings.HasPrefix(apiKey, "sk-") { diff --git a/internal/event/all.go b/internal/event/all.go index 8caf98e62ff3f39b291e341959ebc943361eec05..713421a0186fad28137ac68fabb8d594c305d2e9 100644 --- a/internal/event/all.go +++ b/internal/event/all.go @@ -57,3 +57,7 @@ func TokensUsed(props ...any) { props..., ) } + +func StatsViewed() { + send("stats viewed") +} diff --git a/internal/event/event.go b/internal/event/event.go index 389b6549e35323eef8dbe37ded671c5f33544adc..aaa0d213fc49def2f1307c83366ef9bf19c4b1ae 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -84,7 +84,7 @@ func send(event string, props ...any) { // Error logs an error event to PostHog with the error type and message. func Error(errToLog any, props ...any) { - if client == nil { + if client == nil || errToLog == nil { return } posthogErr := client.Enqueue(posthog.NewDefaultException( diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 18bc1ed954acbf4a0397dfa497bd2133513bb090..2aa1b49a781489598f865cfd067d1adf5d68b7aa 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -125,19 +125,38 @@ func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol return result, nil } +// closeTimeout is the maximum time to wait for a graceful LSP shutdown. +const closeTimeout = 5 * time.Second + // Kill kills the client without doing anything else. func (c *Client) Kill() { c.client.Kill() } -// Close closes all open files in the client, then the client. +// Close closes all open files in the client, then shuts down gracefully. +// If shutdown takes longer than closeTimeout, it falls back to Kill(). func (c *Client) Close(ctx context.Context) error { c.CloseAllFiles(ctx) - // Shutdown and exit the client - if err := c.client.Shutdown(ctx); err != nil { - slog.Warn("Failed to shutdown LSP client", "error", err) - } + // Use a timeout to prevent hanging on unresponsive LSP servers. + // jsonrpc2's send lock doesn't respect context cancellation, so we + // need to fall back to Kill() which closes the underlying connection. + closeCtx, cancel := context.WithTimeout(ctx, closeTimeout) + defer cancel() + + done := make(chan error, 1) + go func() { + if err := c.client.Shutdown(closeCtx); err != nil { + slog.Warn("Failed to shutdown LSP client", "error", err) + } + done <- c.client.Exit() + }() - return c.client.Exit() + select { + case err := <-done: + return err + case <-closeCtx.Done(): + c.client.Kill() + return closeCtx.Err() + } } // createPowernapClient creates a new powernap client with the current configuration. @@ -175,7 +194,7 @@ func (c *Client) createPowernapClient() error { // registerHandlers registers the standard LSP notification and request handlers. func (c *Client) registerHandlers() { - c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit) + c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit(c.client.GetOffsetEncoding())) c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration) c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability) c.RegisterNotificationHandler("window/showMessage", func(ctx context.Context, method string, params json.RawMessage) { @@ -277,10 +296,6 @@ func (c *Client) WaitForServerReady(ctx context.Context) error { // Set initial state c.SetServerState(StateStarting) - // Create a context with timeout - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - // Try to ping the server with a simple request ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() @@ -495,14 +510,9 @@ func (c *Client) RegisterServerRequestHandler(method string, handler transport.H // openKeyConfigFiles opens important configuration files that help initialize the server. func (c *Client) openKeyConfigFiles(ctx context.Context) { - wd, err := os.Getwd() - if err != nil { - return - } - // Try to open each file, ignoring errors if they don't exist for _, file := range c.config.RootMarkers { - file = filepath.Join(wd, file) + file = filepath.Join(c.cwd, file) if _, err := os.Stat(file); err == nil { // File exists, try to open it if err := c.OpenFile(ctx, file); err != nil { @@ -542,6 +552,11 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char if err := c.OpenFileOnDemand(ctx, filepath); err != nil { return nil, err } + + // Add timeout to prevent hanging on slow LSP servers. + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + // NOTE: line and character should be 0-based. // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration) diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 9674ab22c226a4662beb08daa813325b52c079af..5c791e7ac61a821628db3508bf072e569b9e7aaa 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -6,6 +6,7 @@ import ( "log/slog" "github.com/charmbracelet/crush/internal/lsp/util" + powernap "github.com/charmbracelet/x/powernap/pkg/lsp" "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) @@ -44,19 +45,21 @@ func HandleRegisterCapability(_ context.Context, _ string, params json.RawMessag } // HandleApplyEdit handles workspace edit requests -func HandleApplyEdit(_ context.Context, _ string, params json.RawMessage) (any, error) { - var edit protocol.ApplyWorkspaceEditParams - if err := json.Unmarshal(params, &edit); err != nil { - return nil, err - } +func HandleApplyEdit(encoding powernap.OffsetEncoding) func(_ context.Context, _ string, params json.RawMessage) (any, error) { + return func(_ context.Context, _ string, params json.RawMessage) (any, error) { + var edit protocol.ApplyWorkspaceEditParams + if err := json.Unmarshal(params, &edit); err != nil { + return nil, err + } - err := util.ApplyWorkspaceEdit(edit.Edit) - if err != nil { - slog.Error("Error applying workspace edit", "error", err) - return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil - } + err := util.ApplyWorkspaceEdit(edit.Edit, encoding) + if err != nil { + slog.Error("Error applying workspace edit", "error", err) + return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil + } - return protocol.ApplyWorkspaceEditResult{Applied: true}, nil + return protocol.ApplyWorkspaceEditResult{Applied: true}, nil + } } // FileWatchRegistrationHandler is a function that will be called when file watch registrations are received @@ -81,7 +84,7 @@ func notifyFileWatchRegistration(id string, watchers []protocol.FileSystemWatche func HandleServerMessage(_ context.Context, method string, params json.RawMessage) { var msg protocol.ShowMessageParams if err := json.Unmarshal(params, &msg); err != nil { - slog.Debug("Server message", "type", msg.Type, "message", msg.Message) + slog.Debug("Error unmarshal server message", "error", err) return } diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index d6b1eaba5498b71c59566c2fbb1df642b6335c6d..13a78cef2a471a71c1e741e32e08e8d7edcb7484 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -59,9 +59,10 @@ func NewManager(cfg *config.Config) *Manager { } return &Manager{ - clients: csync.NewMap[string, *Client](), - cfg: cfg, - manager: manager, + clients: csync.NewMap[string, *Client](), + cfg: cfg, + manager: manager, + callback: func(string, *Client) {}, // default no-op callback } } @@ -204,7 +205,7 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server if existing, ok := s.clients.Get(name); ok { switch existing.GetServerState() { case StateReady, StateStarting, StateDisabled: - client.Close(ctx) + _ = client.Close(ctx) s.callback(name, existing) return } @@ -227,7 +228,8 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil { slog.Error("LSP client initialization failed", "name", name, "error", err) - client.Close(ctx) + _ = client.Close(ctx) + s.clients.Del(name) return } @@ -301,8 +303,10 @@ func hasRootMarkers(dir string, markers []string) bool { return true } for _, pattern := range markers { - // Use fsext.GlobWithDoubleStar to find matches - matches, _, err := fsext.Glob(pattern, dir, 1) + // Use filepath.Glob for a non-recursive check in the root + // directory. This avoids walking the entire tree (which is + // catastrophic in large monorepos with node_modules, etc.). + matches, err := filepath.Glob(filepath.Join(dir, pattern)) if err == nil && len(matches) > 0 { return true } diff --git a/internal/lsp/util/edit.go b/internal/lsp/util/edit.go index 8b500ac67489e5fbcd0981a012dcf7a0c871f67e..a2b9eef9e917552d48dcf43b14d68554d2209b6f 100644 --- a/internal/lsp/util/edit.go +++ b/internal/lsp/util/edit.go @@ -7,10 +7,11 @@ import ( "sort" "strings" + powernap "github.com/charmbracelet/x/powernap/pkg/lsp" "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) -func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit) error { +func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit, encoding powernap.OffsetEncoding) error { path, err := uri.Path() if err != nil { return fmt.Errorf("invalid URI: %w", err) @@ -57,7 +58,7 @@ func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit) error { // Apply each edit for _, edit := range sortedEdits { - newLines, err := applyTextEdit(lines, edit) + newLines, err := applyTextEdit(lines, edit, encoding) if err != nil { return fmt.Errorf("failed to apply edit: %w", err) } @@ -85,13 +86,11 @@ func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit) error { return nil } -func applyTextEdit(lines []string, edit protocol.TextEdit) ([]string, error) { +func applyTextEdit(lines []string, edit protocol.TextEdit, encoding powernap.OffsetEncoding) ([]string, error) { startLine := int(edit.Range.Start.Line) endLine := int(edit.Range.End.Line) - startChar := int(edit.Range.Start.Character) - endChar := int(edit.Range.End.Character) - // Validate positions + // Validate positions before accessing lines. if startLine < 0 || startLine >= len(lines) { return nil, fmt.Errorf("invalid start line: %d", startLine) } @@ -99,6 +98,26 @@ func applyTextEdit(lines []string, edit protocol.TextEdit) ([]string, error) { endLine = len(lines) - 1 } + var startChar, endChar int + switch encoding { + case powernap.UTF8: + // UTF-8: Character offset is already a byte offset + startChar = int(edit.Range.Start.Character) + endChar = int(edit.Range.End.Character) + case powernap.UTF16: + // UTF-16 (default): Convert to byte offset + startLineContent := lines[startLine] + endLineContent := lines[endLine] + startChar = powernap.PositionToByteOffset(startLineContent, edit.Range.Start.Character) + endChar = powernap.PositionToByteOffset(endLineContent, edit.Range.End.Character) + default: + // UTF-32: Character offset is codepoint count, convert to byte offset + startLineContent := lines[startLine] + endLineContent := lines[endLine] + startChar = utf32ToByteOffset(startLineContent, edit.Range.Start.Character) + endChar = utf32ToByteOffset(endLineContent, edit.Range.End.Character) + } + // Create result slice with initial capacity result := make([]string, 0, len(lines)) @@ -149,7 +168,7 @@ func applyTextEdit(lines []string, edit protocol.TextEdit) ([]string, error) { } // applyDocumentChange applies a DocumentChange (create/rename/delete operations) -func applyDocumentChange(change protocol.DocumentChange) error { +func applyDocumentChange(change protocol.DocumentChange, encoding powernap.OffsetEncoding) error { if change.CreateFile != nil { path, err := change.CreateFile.URI.Path() if err != nil { @@ -222,24 +241,42 @@ func applyDocumentChange(change protocol.DocumentChange) error { return fmt.Errorf("invalid edit type: %w", err) } } - return applyTextEdits(change.TextDocumentEdit.TextDocument.URI, textEdits) + return applyTextEdits(change.TextDocumentEdit.TextDocument.URI, textEdits, encoding) } return nil } -// ApplyWorkspaceEdit applies the given WorkspaceEdit to the filesystem -func ApplyWorkspaceEdit(edit protocol.WorkspaceEdit) error { +// utf32ToByteOffset converts a UTF-32 codepoint offset to a byte offset. +func utf32ToByteOffset(lineText string, codepointOffset uint32) int { + if codepointOffset == 0 { + return 0 + } + + var codepointCount uint32 + for byteOffset := range lineText { + if codepointCount >= codepointOffset { + return byteOffset + } + codepointCount++ + } + return len(lineText) +} + +// ApplyWorkspaceEdit applies the given WorkspaceEdit to the filesystem. +// The encoding parameter specifies the position encoding used by the LSP server +// (UTF8, UTF16, or UTF32). This affects how character offsets are interpreted. +func ApplyWorkspaceEdit(edit protocol.WorkspaceEdit, encoding powernap.OffsetEncoding) error { // Handle Changes field for uri, textEdits := range edit.Changes { - if err := applyTextEdits(uri, textEdits); err != nil { + if err := applyTextEdits(uri, textEdits, encoding); err != nil { return fmt.Errorf("failed to apply text edits: %w", err) } } // Handle DocumentChanges field for _, change := range edit.DocumentChanges { - if err := applyDocumentChange(change); err != nil { + if err := applyDocumentChange(change, encoding); err != nil { return fmt.Errorf("failed to apply document change: %w", err) } } @@ -247,14 +284,18 @@ func ApplyWorkspaceEdit(edit protocol.WorkspaceEdit) error { return nil } +// rangesOverlap checks if two LSP ranges overlap. +// Per the LSP specification, ranges are half-open intervals [start, end), +// so adjacent ranges where one's end equals another's start do NOT overlap. +// See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#range func rangesOverlap(r1, r2 protocol.Range) bool { if r1.Start.Line > r2.End.Line || r2.Start.Line > r1.End.Line { return false } - if r1.Start.Line == r2.End.Line && r1.Start.Character > r2.End.Character { + if r1.Start.Line == r2.End.Line && r1.Start.Character >= r2.End.Character { return false } - if r2.Start.Line == r1.End.Line && r2.Start.Character > r1.End.Character { + if r2.Start.Line == r1.End.Line && r2.Start.Character >= r1.End.Character { return false } return true diff --git a/internal/lsp/util/edit_test.go b/internal/lsp/util/edit_test.go new file mode 100644 index 0000000000000000000000000000000000000000..8e7d12fc9cffedffb8b701f0a8c8040f162327fa --- /dev/null +++ b/internal/lsp/util/edit_test.go @@ -0,0 +1,376 @@ +package util + +import ( + "testing" + + powernap "github.com/charmbracelet/x/powernap/pkg/lsp" + "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" + "github.com/stretchr/testify/require" +) + +func TestPositionToByteOffset(t *testing.T) { + tests := []struct { + name string + lineText string + utf16Char uint32 + expected int + }{ + { + name: "ASCII only", + lineText: "hello world", + utf16Char: 6, + expected: 6, + }, + { + name: "CJK characters (3 bytes each in UTF-8, 1 UTF-16 unit)", + lineText: "你好world", + utf16Char: 2, + expected: 6, + }, + { + name: "CJK - position after CJK", + lineText: "var x = \"你好world\"", + utf16Char: 11, + expected: 15, + }, + { + name: "Emoji (4 bytes in UTF-8, 2 UTF-16 units)", + lineText: "👋hello", + utf16Char: 2, + expected: 4, + }, + { + name: "Multiple emoji", + lineText: "👋👋world", + utf16Char: 4, + expected: 8, + }, + { + name: "Mixed content", + lineText: "Hello👋你好", + utf16Char: 8, + expected: 12, + }, + { + name: "Position 0", + lineText: "hello", + utf16Char: 0, + expected: 0, + }, + { + name: "Position beyond end", + lineText: "hi", + utf16Char: 100, + expected: 2, + }, + { + name: "Empty string", + lineText: "", + utf16Char: 0, + expected: 0, + }, + { + name: "Surrogate pair at start", + lineText: "𐐷hello", + utf16Char: 2, + expected: 4, + }, + { + name: "ZWJ family emoji (1 grapheme, 7 runes, 11 UTF-16 units)", + lineText: "hello👨\u200d👩\u200d👧\u200d👦world", + utf16Char: 16, + expected: 30, + }, + { + name: "ZWJ family emoji - offset into middle of grapheme cluster", + lineText: "hello👨\u200d👩\u200d👧\u200d👦world", + utf16Char: 8, + expected: 12, + }, + { + name: "Flag emoji (1 grapheme, 2 runes, 4 UTF-16 units)", + lineText: "hello🇺🇸world", + utf16Char: 9, + expected: 13, + }, + { + name: "Combining character (1 grapheme, 2 runes, 2 UTF-16 units)", + lineText: "caf\u0065\u0301!", + utf16Char: 5, + expected: 6, + }, + { + name: "Skin tone modifier (1 grapheme, 2 runes, 4 UTF-16 units)", + lineText: "hi👋🏽bye", + utf16Char: 6, + expected: 10, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := powernap.PositionToByteOffset(tt.lineText, tt.utf16Char) + if result != tt.expected { + t.Errorf("PositionToByteOffset(%q, %d) = %d, want %d", + tt.lineText, tt.utf16Char, result, tt.expected) + } + }) + } +} + +func TestApplyTextEdit_UTF16(t *testing.T) { + // Test that UTF-16 offsets are correctly converted to byte offsets + tests := []struct { + name string + lines []string + edit protocol.TextEdit + expected []string + }{ + { + name: "ASCII only - no conversion needed", + lines: []string{"hello world"}, + edit: protocol.TextEdit{ + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 6}, + End: protocol.Position{Line: 0, Character: 11}, + }, + NewText: "universe", + }, + expected: []string{"hello universe"}, + }, + { + name: "CJK characters - edit after Chinese characters", + lines: []string{`var x = "你好world"`}, + edit: protocol.TextEdit{ + Range: protocol.Range{ + // "你好" = 2 UTF-16 units, but 6 bytes in UTF-8 + // Position 11 is where "world" starts in UTF-16 + Start: protocol.Position{Line: 0, Character: 11}, + End: protocol.Position{Line: 0, Character: 16}, + }, + NewText: "universe", + }, + expected: []string{`var x = "你好universe"`}, + }, + { + name: "Emoji - edit after emoji (2 UTF-16 units)", + lines: []string{`fmt.Println("👋hello")`}, + edit: protocol.TextEdit{ + Range: protocol.Range{ + // 👋 = 2 UTF-16 units, 4 bytes in UTF-8 + // Position 15 is where "hello" starts in UTF-16 + Start: protocol.Position{Line: 0, Character: 15}, + End: protocol.Position{Line: 0, Character: 20}, + }, + NewText: "world", + }, + expected: []string{`fmt.Println("👋world")`}, + }, + { + name: "ZWJ family emoji - edit after grapheme cluster", + // "hello👨‍👩‍👧‍👦world" — family is 1 grapheme but 11 UTF-16 units + lines: []string{"hello\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466world"}, + edit: protocol.TextEdit{ + Range: protocol.Range{ + // "hello" = 5 UTF-16 units, family = 11 UTF-16 units + // "world" starts at UTF-16 offset 16 + Start: protocol.Position{Line: 0, Character: 16}, + End: protocol.Position{Line: 0, Character: 21}, + }, + NewText: "earth", + }, + expected: []string{"hello\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466earth"}, + }, + { + name: "ZWJ family emoji - edit splits grapheme cluster in half", + // LSP servers can position into the middle of a grapheme cluster. + // After "hello" (5 UTF-16 units), the ZWJ family emoji starts. + // UTF-16 offset 7 lands between 👨 (2 units) and ZWJ, inside + // the grapheme cluster. The byte offset for position 7 is 9 + // (5 bytes for "hello" + 4 bytes for 👨). + lines: []string{"hello\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466world"}, + edit: protocol.TextEdit{ + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 7}, + End: protocol.Position{Line: 0, Character: 16}, + }, + NewText: "", + }, + // Keeps "hello" + 👨 (first rune of cluster) then removes + // the rest of the cluster, leaving "hello👨world". + expected: []string{"hello\U0001F468world"}, + }, + { + name: "Flag emoji - edit after flag", + // 🇺🇸 = 2 regional indicator runes, 4 UTF-16 units, 8 bytes + lines: []string{"hello🇺🇸world"}, + edit: protocol.TextEdit{ + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 9}, + End: protocol.Position{Line: 0, Character: 14}, + }, + NewText: "earth", + }, + expected: []string{"hello🇺🇸earth"}, + }, + { + name: "Combining accent - edit after composed character", + // "café!" where é = e + U+0301 (2 code points, 2 UTF-16 units) + lines: []string{"caf\u0065\u0301!"}, + edit: protocol.TextEdit{ + Range: protocol.Range{ + // "caf" = 3, "e" = 1, U+0301 = 1, total = 5 UTF-16 units + Start: protocol.Position{Line: 0, Character: 5}, + End: protocol.Position{Line: 0, Character: 6}, + }, + NewText: "?", + }, + expected: []string{"caf\u0065\u0301?"}, + }, + { + name: "Skin tone modifier - edit after modified emoji", + // 👋🏽 = U+1F44B U+1F3FD = 2 runes, 4 UTF-16 units, 8 bytes + lines: []string{"hi👋🏽bye"}, + edit: protocol.TextEdit{ + Range: protocol.Range{ + // "hi" = 2, 👋🏽 = 4, total = 6 UTF-16 units + Start: protocol.Position{Line: 0, Character: 6}, + End: protocol.Position{Line: 0, Character: 9}, + }, + NewText: "later", + }, + expected: []string{"hi👋🏽later"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := applyTextEdit(tt.lines, tt.edit, powernap.UTF16) + if err != nil { + t.Fatalf("applyTextEdit failed: %v", err) + } + if len(result) != len(tt.expected) { + t.Errorf("expected %d lines, got %d: %v", len(tt.expected), len(result), result) + return + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("line %d: expected %q, got %q", i, tt.expected[i], result[i]) + } + } + }) + } +} + +func TestApplyTextEdit_UTF8(t *testing.T) { + // Test that UTF-8 offsets are used directly without conversion + tests := []struct { + name string + lines []string + edit protocol.TextEdit + expected []string + }{ + { + name: "ASCII only - direct byte offset", + lines: []string{"hello world"}, + edit: protocol.TextEdit{ + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 6}, + End: protocol.Position{Line: 0, Character: 11}, + }, + NewText: "universe", + }, + expected: []string{"hello universe"}, + }, + { + name: "CJK characters - byte offset used directly", + lines: []string{`var x = "你好world"`}, + edit: protocol.TextEdit{ + Range: protocol.Range{ + // With UTF-8 encoding, position 15 is the byte offset + Start: protocol.Position{Line: 0, Character: 15}, + End: protocol.Position{Line: 0, Character: 20}, + }, + NewText: "universe", + }, + expected: []string{`var x = "你好universe"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := applyTextEdit(tt.lines, tt.edit, powernap.UTF8) + if err != nil { + t.Fatalf("applyTextEdit failed: %v", err) + } + if len(result) != len(tt.expected) { + t.Errorf("expected %d lines, got %d: %v", len(tt.expected), len(result), result) + return + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("line %d: expected %q, got %q", i, tt.expected[i], result[i]) + } + } + }) + } +} + +func TestRangesOverlap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + r1 protocol.Range + r2 protocol.Range + want bool + }{ + { + name: "adjacent ranges do not overlap", + r1: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: protocol.Position{Line: 0, Character: 5}, + }, + r2: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 5}, + End: protocol.Position{Line: 0, Character: 10}, + }, + want: false, + }, + { + name: "overlapping ranges", + r1: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: protocol.Position{Line: 0, Character: 8}, + }, + r2: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 5}, + End: protocol.Position{Line: 0, Character: 10}, + }, + want: true, + }, + { + name: "non-overlapping with gap", + r1: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: protocol.Position{Line: 0, Character: 3}, + }, + r2: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 7}, + End: protocol.Position{Line: 0, Character: 10}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := rangesOverlap(tt.r1, tt.r2) + require.Equal(t, tt.want, got, "rangesOverlap(r1, r2)") + // Overlap should be symmetric + got2 := rangesOverlap(tt.r2, tt.r1) + require.Equal(t, tt.want, got2, "rangesOverlap(r2, r1) symmetry") + }) + } +} diff --git a/internal/shell/background.go b/internal/shell/background.go index c6a0f81e2c4c0b9de19a599b07f58cf7225d32a2..cbcf7d3fe62005c7ee7af83831134a2c42d1bf84 100644 --- a/internal/shell/background.go +++ b/internal/shell/background.go @@ -234,3 +234,12 @@ func (bs *BackgroundShell) IsDone() bool { func (bs *BackgroundShell) Wait() { <-bs.done } + +func (bs *BackgroundShell) WaitContext(ctx context.Context) bool { + select { + case <-bs.done: + return true + case <-ctx.Done(): + return false + } +} diff --git a/internal/shell/background_test.go b/internal/shell/background_test.go index 62a43514825bd6428e5928ccd704b46b7d9e8b6f..9926fb1fd94241d1ea8a2411a8a570c0a1018386 100644 --- a/internal/shell/background_test.go +++ b/internal/shell/background_test.go @@ -307,3 +307,28 @@ func TestBackgroundShellManager_KillAll_Timeout(t *testing.T) { // Must return promptly after timeout, not hang for 60 seconds. require.Less(t, elapsed, 2*time.Second) } + +func TestBackgroundShell_WaitContext_Completed(t *testing.T) { + t.Parallel() + + done := make(chan struct{}) + close(done) + + bgShell := &BackgroundShell{done: done} + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + t.Cleanup(cancel) + + require.True(t, bgShell.WaitContext(ctx)) +} + +func TestBackgroundShell_WaitContext_Canceled(t *testing.T) { + t.Parallel() + + bgShell := &BackgroundShell{done: make(chan struct{})} + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + require.False(t, bgShell.WaitContext(ctx)) +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index b777a4c1fac31324311595580b1f48207244accd..941081870bd2a891d37017949324e84a01cb1c91 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -1339,7 +1339,7 @@ func (t *baseToolMessageItem) formatWebFetchResultForCopy() string { } var result strings.Builder - result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL)) + fmt.Fprintf(&result, "URL: %s\n\n", params.URL) result.WriteString("```markdown\n") result.WriteString(t.result.Content) result.WriteString("\n```") @@ -1356,7 +1356,7 @@ func (t *baseToolMessageItem) formatAgentResultForCopy() string { var result strings.Builder if t.result.Content != "" { - result.WriteString(fmt.Sprintf("```markdown\n%s\n```", t.result.Content)) + fmt.Fprintf(&result, "```markdown\n%s\n```", t.result.Content) } return result.String() diff --git a/internal/ui/model/status.go b/internal/ui/model/status.go index 00f637832cf67a65efb66630308234f353169d3c..fbec7792bd9f6fbc9445036323f9a425438c200d 100644 --- a/internal/ui/model/status.go +++ b/internal/ui/model/status.go @@ -1,6 +1,7 @@ package model import ( + "strings" "time" "charm.land/bubbles/v2/help" @@ -99,9 +100,10 @@ func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) { } ind := indStyle.String() - messageWidth := area.Dx() - lipgloss.Width(ind) + messageWidth := max(0, area.Dx()-lipgloss.Width(ind)-msgStyle.GetHorizontalPadding()) msg := ansi.Truncate(s.msg.Msg, messageWidth, "…") - info := msgStyle.Width(messageWidth).Render(msg) + msg += strings.Repeat(" ", max(0, messageWidth-lipgloss.Width(msg))) + info := msgStyle.Render(msg) // Draw the info message over the help view uv.NewStyledString(ind+info).Draw(scr, area) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 7480f7755f108a75c2cf3ea7f68a5adaf5d11e9e..c183344e36885baa85377da057f59590d95df088 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -554,6 +554,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.updateLayoutAndSize() + if m.state == uiChat && m.chat.Follow() { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } case tea.KeyboardEnhancementsMsg: m.keyenh = msg if msg.SupportsKeyDisambiguation() { @@ -678,7 +683,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { - m.chat.SelectNext() + if m.chat.AtBottom() { + m.chat.SelectLast() + } else { + m.chat.SelectNext() + } if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } @@ -690,6 +699,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd := m.chat.Animate(msg); cmd != nil { cmds = append(cmds, cmd) } + if m.chat.Follow() { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } } case spinner.TickMsg: if m.dialog.HasDialogs() { @@ -820,7 +834,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { cmds = append(cmds, cmd) } m.chat.SelectLast() - return tea.Batch(cmds...) + return tea.Sequence(cmds...) } // loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools. @@ -948,7 +962,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { } } } - return tea.Batch(cmds...) + return tea.Sequence(cmds...) } func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) { @@ -1027,16 +1041,16 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } + m.chat.SelectLast() } - return tea.Batch(cmds...) + return tea.Sequence(cmds...) } // handleChildSessionMessage handles messages from child sessions (agent tools). func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd { var cmds []tea.Cmd - atBottom := m.chat.AtBottom() // Only process messages with tool calls or results. if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 { return nil @@ -1116,13 +1130,14 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea. // Update the chat so it updates the index map for animations to work as expected m.chat.UpdateNestedToolIDs(toolCallID) - if atBottom { + if m.chat.Follow() { if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } + m.chat.SelectLast() } - return tea.Batch(cmds...) + return tea.Sequence(cmds...) } func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { @@ -1808,7 +1823,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { handleGlobalKeys(msg) } - return tea.Batch(cmds...) + return tea.Sequence(cmds...) } // drawHeader draws the header section of the UI.