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.