From 407ab1f6e2564c8110c9fdb9dd4034af87e4c25c Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 24 Feb 2026 17:43:34 -0300 Subject: [PATCH 01/51] chore: update fantasy to v0.10.0 --- go.mod | 14 +++++++------- go.sum | 38 ++++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/go.mod b/go.mod index c47e9b613eac3b7e1fdce1063b8ce623d2fb2c35..76fa07f8d3ec01542be892a42f3ad812c040bd6f 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ 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/fantasy v0.10.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 @@ -94,18 +94,18 @@ require ( 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/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 @@ -129,9 +129,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,7 +180,7 @@ 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/api v0.267.0 // indirect google.golang.org/genai v1.47.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/grpc v1.79.1 // indirect diff --git a/go.sum b/go.sum index 685c4df3f6ed6efcab0002e61088fa7ecb37c49b..c3a8dbccf9c7f80bbabe8f86f7716cacf4ca5e50 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ 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/fantasy v0.10.0 h1:6PD+1rrsCgLIG1n+PAZp/gHiC0dltU0cvb7c8zUKyu8= +charm.land/fantasy v0.10.0/go.mod h1:KIeNQUpJTswwpY0P6HJsr3LBFgfTDb8FDpOdVQMsKqY= 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= @@ -76,8 +76,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4M 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/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= @@ -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= @@ -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= @@ -491,8 +493,8 @@ 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/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.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo= google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= From a439237bdb5785f16505fe76190136f4db49a12b Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 24 Feb 2026 17:45:11 -0300 Subject: [PATCH 02/51] chore: update catwalk to v0.22.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 76fa07f8d3ec01542be892a42f3ad812c040bd6f..0a077294ccfc9df440cec45734cfc8f3aceca6c0 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ 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/catwalk v0.22.0 charm.land/fantasy v0.10.0 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b charm.land/lipgloss/v2 v2.0.0 diff --git a/go.sum b/go.sum index c3a8dbccf9c7f80bbabe8f86f7716cacf4ca5e50..8ac7db3df72d0ca4bb973aa9aad8629871151b42 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ 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/catwalk v0.22.0 h1:XNvXWuTlYPrX+EMigIX1/VPm8zmgbt7p04fJfBHODY8= +charm.land/catwalk v0.22.0/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= charm.land/fantasy v0.10.0 h1:6PD+1rrsCgLIG1n+PAZp/gHiC0dltU0cvb7c8zUKyu8= charm.land/fantasy v0.10.0/go.mod h1:KIeNQUpJTswwpY0P6HJsr3LBFgfTDb8FDpOdVQMsKqY= charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0= From 841165eba07a62808453ba68348c7a097f0d2559 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 24 Feb 2026 17:08:50 -0300 Subject: [PATCH 03/51] feat: add support or gemini 3+ thinking levels --- internal/agent/coordinator.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 27d2f7163b3607245b88c8ca32a142cee42e93e9..81825a9ea9a7d0a328f5e839b574994b1cd91faa 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -317,9 +317,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) From 643919d24d8e517faeaa81c57241303615db475a Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 25 Feb 2026 10:18:23 -0300 Subject: [PATCH 05/51] fix: initialize lsp manager callback to prevent nil pointer panic (#2307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `callback` field was `nil` when the app returned early before `SetCallback` was called, causing a segfault in `startServer`. 💘 Generated with Crush Assisted-by: Claude Opus 4.6 via Crush --- internal/lsp/manager.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index d6b1eaba5498b71c59566c2fbb1df642b6335c6d..d77b0cc673428045198f38757962c1326573dece 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 } } From 62b8c0b3def656ebbdb4f2e39e5ea4e97dfbd975 Mon Sep 17 00:00:00 2001 From: huaiyuWangh <34158348+huaiyuWangh@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:19:44 +0800 Subject: [PATCH 06/51] fix(lsp): fix multiple bugs in lsp client lifecycle and handlers (#2305) - Remove dead client from map when initialization fails to allow retry - Use client's cwd field instead of os.Getwd() in openKeyConfigFiles - Fix slog key-value pair in HandleServerMessage error logging - Remove redundant hardcoded timeout in WaitForServerReady --- internal/lsp/client.go | 11 +---------- internal/lsp/handlers.go | 2 +- internal/lsp/manager.go | 1 + 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 18bc1ed954acbf4a0397dfa497bd2133513bb090..bc8d31dba0360aed813d4b9cf6e5b769bb2559d4 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -277,10 +277,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 +491,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 { diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 9674ab22c226a4662beb08daa813325b52c079af..63ad93b2dd1cbc856eda4a9e41884dfc27e93870 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -81,7 +81,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 d77b0cc673428045198f38757962c1326573dece..f436b3cb3433d9c7e6c5ef28a3cff8bfa17f447f 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -229,6 +229,7 @@ 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) + s.clients.Del(name) return } From 655ce65564a2813bae27d6be0a7562b6a28e60e4 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 25 Feb 2026 11:38:02 -0500 Subject: [PATCH 07/51] fix(ui): truncate status messages that would otherwise wrap (#2306) --- internal/ui/model/status.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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) From 3994293474aa5b5c60e6f79a82d00e4e68edebd5 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 24 Feb 2026 20:40:32 -0500 Subject: [PATCH 08/51] chore(events): log when crush stats is called --- internal/cmd/stats.go | 3 +++ internal/event/all.go | 4 ++++ 2 files changed, 7 insertions(+) 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/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") +} From 4018154c7aacbc73aae07decd239b71c450b7ed9 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 25 Feb 2026 15:46:38 -0500 Subject: [PATCH 09/51] fix(event): guard against panic (#2310) --- internal/event/event.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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( From c9fec16209f049eb1940eed03c0082817d914c24 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 25 Feb 2026 17:59:08 -0300 Subject: [PATCH 10/51] chore: update catwalk --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0a077294ccfc9df440cec45734cfc8f3aceca6c0..0c21b3ad3a72ab9d4546c504512bebeb7b886449 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.0 - charm.land/catwalk v0.22.0 + charm.land/catwalk v0.22.1 charm.land/fantasy v0.10.0 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b charm.land/lipgloss/v2 v2.0.0 diff --git a/go.sum b/go.sum index 8ac7db3df72d0ca4bb973aa9aad8629871151b42..f428c3e9ee9c54826636d98d23cb12bf4b6a2519 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ 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.22.0 h1:XNvXWuTlYPrX+EMigIX1/VPm8zmgbt7p04fJfBHODY8= -charm.land/catwalk v0.22.0/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= +charm.land/catwalk v0.22.1 h1:i7nxxYyEzgWqDD3ifAZ8SQR/cEaPbviiBbxq+ZGhk6M= +charm.land/catwalk v0.22.1/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= charm.land/fantasy v0.10.0 h1:6PD+1rrsCgLIG1n+PAZp/gHiC0dltU0cvb7c8zUKyu8= charm.land/fantasy v0.10.0/go.mod h1:KIeNQUpJTswwpY0P6HJsr3LBFgfTDb8FDpOdVQMsKqY= charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0= From 8d6433b3999b1caaa3b7ea667c8f136a16182395 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 25 Feb 2026 18:01:16 -0300 Subject: [PATCH 11/51] ci: notify me on winget prs --- .goreleaser.yml | 2 ++ 1 file changed, 2 insertions(+) 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 From 1dbde3e34f5fa638bf87678a6b0477bfe0c62e96 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:57:00 -0300 Subject: [PATCH 13/51] chore(legal): @mavaa has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 4026b8c990a227a4a99f6193ff798f4f383e5f09..31794538c8287f9e4714c806fa7c244c7ab85e35 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1287,6 +1287,14 @@ "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 } ] } \ No newline at end of file From b726b20aeba20cc68a96649228b14ef8643bd937 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:23:57 -0300 Subject: [PATCH 14/51] chore(legal): @aisk has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 31794538c8287f9e4714c806fa7c244c7ab85e35..2c5339654d92310286dc767bdb564975cb611a70 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1295,6 +1295,14 @@ "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 } ] } \ No newline at end of file From cd34bd6f2cb5f22214abb47e8f5741196c140ef0 Mon Sep 17 00:00:00 2001 From: AN Long Date: Fri, 27 Feb 2026 02:53:28 +0900 Subject: [PATCH 15/51] feat: add minimax china provider (#2315) Co-authored-by: Andrey Nering --- go.mod | 2 +- go.sum | 4 ++-- internal/agent/coordinator.go | 2 +- internal/config/config.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 0c21b3ad3a72ab9d4546c504512bebeb7b886449..67ae0f96a0505846c4254f3efb639f203cc05828 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.0 - charm.land/catwalk v0.22.1 + charm.land/catwalk v0.23.0 charm.land/fantasy v0.10.0 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b charm.land/lipgloss/v2 v2.0.0 diff --git a/go.sum b/go.sum index f428c3e9ee9c54826636d98d23cb12bf4b6a2519..17b42afbc09261c51ccbb5a5decae07eb36a30bb 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ 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.22.1 h1:i7nxxYyEzgWqDD3ifAZ8SQR/cEaPbviiBbxq+ZGhk6M= -charm.land/catwalk v0.22.1/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= +charm.land/catwalk v0.23.0 h1:zxErKwfc2EG4jH09ZQPDSR2i3gByS46GKc0ryINLT7I= +charm.land/catwalk v0.23.0/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= charm.land/fantasy v0.10.0 h1:6PD+1rrsCgLIG1n+PAZp/gHiC0dltU0cvb7c8zUKyu8= charm.land/fantasy v0.10.0/go.mod h1:KIeNQUpJTswwpY0P6HJsr3LBFgfTDb8FDpOdVQMsKqY= charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0= diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 81825a9ea9a7d0a328f5e839b574994b1cd91faa..88ac735468c6c9d260e46da918b7785966f36dc1 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -582,7 +582,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 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-") { From c6f854605cd8918932d823363a398c03f7595335 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 26 Feb 2026 16:41:04 -0300 Subject: [PATCH 16/51] feat: add support for anthropic thinking effort (#2318) --- go.mod | 30 +++++++++--------- go.sum | 60 +++++++++++++++++------------------ internal/agent/coordinator.go | 15 +++++---- 3 files changed, 54 insertions(+), 51 deletions(-) diff --git a/go.mod b/go.mod index 67ae0f96a0505846c4254f3efb639f203cc05828..c9d25b9bd00de5494cb54e3b2facb8064469a9fd 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.23.0 - charm.land/fantasy v0.10.0 + charm.land/catwalk v0.24.0 + 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 @@ -80,20 +80,20 @@ 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/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 @@ -181,7 +181,7 @@ require ( golang.org/x/term v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.267.0 // indirect - google.golang.org/genai v1.47.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 17b42afbc09261c51ccbb5a5decae07eb36a30bb..3039b4b7cc55c0a8cd6f1b8727b8f0319f0c1f78 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.23.0 h1:zxErKwfc2EG4jH09ZQPDSR2i3gByS46GKc0ryINLT7I= -charm.land/catwalk v0.23.0/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= -charm.land/fantasy v0.10.0 h1:6PD+1rrsCgLIG1n+PAZp/gHiC0dltU0cvb7c8zUKyu8= -charm.land/fantasy v0.10.0/go.mod h1:KIeNQUpJTswwpY0P6HJsr3LBFgfTDb8FDpOdVQMsKqY= +charm.land/catwalk v0.24.0 h1:uR+gXIjJTg5jF5Fg9W8fmPOQb9rGPWlczIP2NVZEIlI= +charm.land/catwalk v0.24.0/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,34 +48,34 @@ 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/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= @@ -495,8 +495,8 @@ 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.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= google.golang.org/api v0.267.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/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/coordinator.go b/internal/agent/coordinator.go index 88ac735468c6c9d260e46da918b7785966f36dc1..0b070a24d346ecd649459e11dc71430873bf2788 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -278,12 +278,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 { From 24f99f0d93aa9b71abb72ef8c89bc939cca71b78 Mon Sep 17 00:00:00 2001 From: Austin Cherry Date: Thu, 26 Feb 2026 13:42:57 -0600 Subject: [PATCH 17/51] fix(lsp): replace recursive fastwalk with filepath.Glob in root marker detection (#2316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasRootMarkers used fsext.Glob which triggered a full recursive fastwalk of the working directory with no gitignore filtering. In large JS monorepos this walked millions of files in node_modules, causing 800% CPU usage. Root markers are simple filenames (go.mod, package.json, *.gpr, etc.) that only need a single-directory check. 🐘 Generated with Crush Assisted-by: AWS Claude Opus 4.6 via Crush --- internal/lsp/manager.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index f436b3cb3433d9c7e6c5ef28a3cff8bfa17f447f..5e238fda296a5e28034482a3a0b163ae1ae04d6c 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -303,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 } From 07d065d780610a4a37e516733da951888293bf4b Mon Sep 17 00:00:00 2001 From: "wanghuaiyu@qiniu.com" Date: Sun, 15 Feb 2026 22:43:35 +0800 Subject: [PATCH 19/51] refactor: simplify context value retrieval using generics - Introduce generic getContextValue helper function to eliminate code duplication - Reduce code from 75 to 56 lines (25.3% reduction) - Simplify Get*FromContext functions from ~13 lines to 2 lines each - Add comprehensive test coverage (18 test cases) for context functions - Maintain backward compatibility with existing API --- internal/agent/tools/context_test.go | 219 +++++++++++++++++++++++++++ internal/agent/tools/tools.go | 52 +++---- 2 files changed, 236 insertions(+), 35 deletions(-) create mode 100644 internal/agent/tools/context_test.go diff --git a/internal/agent/tools/context_test.go b/internal/agent/tools/context_test.go new file mode 100644 index 0000000000000000000000000000000000000000..67a106bf23f9721fb8cb025dd2a6a7d9f349b188 --- /dev/null +++ b/internal/agent/tools/context_test.go @@ -0,0 +1,219 @@ +package tools + +import ( + "context" + "testing" +) + +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, "boolKey", true) + }, + key: "boolKey", + defaultValue: false, + want: true, + }, + { + name: "returns int value", + setup: func(ctx context.Context) context.Context { + return context.WithValue(ctx, "intKey", 42) + }, + key: "intKey", + 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/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, "") } From e0d545486846749445745269aabb30c73bba628f Mon Sep 17 00:00:00 2001 From: "wanghuaiyu@qiniu.com" Date: Fri, 20 Feb 2026 16:53:22 +0800 Subject: [PATCH 20/51] refactor: extract common sub-agent execution logic - Add runSubAgent and runSubAgentWithOptions methods to coordinator - Simplify agent_tool.go from 106 to 60 lines (43.4% reduction) - Simplify agentic_fetch_tool.go from 235 to 200 lines (14.9% reduction) - Eliminate 81 lines of duplicated session management code - Support custom session setup via callback for special cases - Improve error handling with proper error wrapping - Add updateParentSessionCost helper for consistent cost propagation --- internal/agent/agent_tool.go | 47 +------------- internal/agent/agentic_fetch_tool.go | 58 ++++-------------- internal/agent/coordinator.go | 92 ++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 92 deletions(-) diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go index 29566b1c5a00d00c1254a3f07cdcef71ba55d59e..5c9a95fb7f210625c9a4a04a803dcfc634f471a3 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,6 @@ 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, - }) - 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 + return c.runSubAgent(ctx, agent, sessionID, agentMessageID, call.ID, params.Prompt, "New Agent Session") }), nil } diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 2d52814d446581fca0e7a98368ffaae465aedf2c..26ed301eaff7955dac95bd2e0043490730e46f85 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -184,51 +184,17 @@ 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, - }) - 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 + return c.runSubAgentWithOptions( + ctx, + agent, + validationResult.SessionID, + validationResult.AgentMessageID, + call.ID, + fullPrompt, + "Fetch Analysis", + func(sessionID string) { + c.permissions.AutoApproveSession(sessionID) + }, + ) }), nil } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 0b070a24d346ecd649459e11dc71430873bf2788..57b42d12de65e3d194ee9b1adaf96392082e3f55 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -940,3 +940,95 @@ func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg con } return nil } + +// 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, + agent SessionAgent, + sessionID, agentMessageID, toolCallID string, + prompt string, + sessionTitle string, +) (fantasy.ToolResponse, error) { + return c.runSubAgentWithOptions(ctx, agent, sessionID, agentMessageID, toolCallID, prompt, sessionTitle, nil) +} + +// runSubAgentWithOptions runs a sub-agent with additional session configuration options. +// The sessionSetup function is called after session creation but before agent execution. +func (c *coordinator) runSubAgentWithOptions( + ctx context.Context, + agent SessionAgent, + sessionID, agentMessageID, toolCallID string, + prompt string, + sessionTitle string, + sessionSetup func(sessionID string), +) (fantasy.ToolResponse, error) { + // Create sub-session + agentToolSessionID := c.sessions.CreateAgentToolSessionID(agentMessageID, toolCallID) + session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, sessionID, sessionTitle) + if err != nil { + return fantasy.ToolResponse{}, fmt.Errorf("create session: %w", err) + } + + // Call session setup function if provided + if sessionSetup != nil { + sessionSetup(session.ID) + } + + // Get model configuration + 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") + } + + // Run the agent + result, err := agent.Run(ctx, SessionAgentCall{ + SessionID: session.ID, + Prompt: 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, 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 +} From 6e5bbef78da317dcde97bec412542fcb5a37891e Mon Sep 17 00:00:00 2001 From: "wanghuaiyu@qiniu.com" Date: Fri, 20 Feb 2026 17:03:10 +0800 Subject: [PATCH 21/51] fix: use typed context keys in tests to satisfy staticcheck - Define custom key types (testStringKey, testBoolKey, testIntKey) - Replace string literals with typed constants - Fixes SA1029: should not use built-in type string as key for value --- internal/agent/tools/context_test.go | 32 ++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/internal/agent/tools/context_test.go b/internal/agent/tools/context_test.go index 67a106bf23f9721fb8cb025dd2a6a7d9f349b188..ee57313f36ae38df1412656118fbd1bbbf707d0b 100644 --- a/internal/agent/tools/context_test.go +++ b/internal/agent/tools/context_test.go @@ -5,6 +5,20 @@ import ( "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 @@ -16,9 +30,9 @@ func TestGetContextValue(t *testing.T) { { name: "returns string value", setup: func(ctx context.Context) context.Context { - return context.WithValue(ctx, "testKey", "testValue") + return context.WithValue(ctx, testKey, "testValue") }, - key: "testKey", + key: testKey, defaultValue: "", want: "testValue", }, @@ -27,34 +41,34 @@ func TestGetContextValue(t *testing.T) { setup: func(ctx context.Context) context.Context { return ctx }, - key: "missingKey", + 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 + return context.WithValue(ctx, testKey, 123) // int, not string }, - key: "testKey", + key: testKey, defaultValue: "default", want: "default", }, { name: "returns bool value", setup: func(ctx context.Context) context.Context { - return context.WithValue(ctx, "boolKey", true) + return context.WithValue(ctx, boolTestKey, true) }, - key: "boolKey", + key: boolTestKey, defaultValue: false, want: true, }, { name: "returns int value", setup: func(ctx context.Context) context.Context { - return context.WithValue(ctx, "intKey", 42) + return context.WithValue(ctx, intTestKey, 42) }, - key: "intKey", + key: intTestKey, defaultValue: 0, want: 42, }, From 42aee6d2bde0d48d0d752629f5c3e9791c2f2a7c Mon Sep 17 00:00:00 2001 From: "wanghuaiyu@qiniu.com" Date: Sun, 22 Feb 2026 17:29:05 +0800 Subject: [PATCH 22/51] refactor: use params struct for runSubAgent and add unit tests --- internal/agent/agent_tool.go | 9 +- internal/agent/agentic_fetch_tool.go | 19 +- internal/agent/coordinator.go | 50 ++-- internal/agent/coordinator_test.go | 385 +++++++++++++++++++++++++++ 4 files changed, 424 insertions(+), 39 deletions(-) create mode 100644 internal/agent/coordinator_test.go diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go index 5c9a95fb7f210625c9a4a04a803dcfc634f471a3..1a7286e342d245c7e7ac1161111d8c205300018b 100644 --- a/internal/agent/agent_tool.go +++ b/internal/agent/agent_tool.go @@ -55,6 +55,13 @@ func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) return fantasy.ToolResponse{}, errors.New("agent message id missing from context") } - return c.runSubAgent(ctx, agent, sessionID, agentMessageID, call.ID, params.Prompt, "New Agent Session") + return c.runSubAgent(ctx, subAgentParams{ + Agent: agent, + SessionID: sessionID, + AgentMessageID: agentMessageID, + ToolCallID: call.ID, + Prompt: params.Prompt, + SessionTitle: "New Agent Session", + }) }), nil } diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 26ed301eaff7955dac95bd2e0043490730e46f85..0bd942e013b706389fb90352c891a4f2ea014f30 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -184,17 +184,16 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( Tools: fetchTools, }) - return c.runSubAgentWithOptions( - ctx, - agent, - validationResult.SessionID, - validationResult.AgentMessageID, - call.ID, - fullPrompt, - "Fetch Analysis", - func(sessionID string) { + 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) }, - ) + }) }), nil } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 57b42d12de65e3d194ee9b1adaf96392082e3f55..40076c34ee429816e93d5c5082f598a5dd02ec6c 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -941,43 +941,37 @@ 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, - agent SessionAgent, - sessionID, agentMessageID, toolCallID string, - prompt string, - sessionTitle string, -) (fantasy.ToolResponse, error) { - return c.runSubAgentWithOptions(ctx, agent, sessionID, agentMessageID, toolCallID, prompt, sessionTitle, nil) -} - -// runSubAgentWithOptions runs a sub-agent with additional session configuration options. -// The sessionSetup function is called after session creation but before agent execution. -func (c *coordinator) runSubAgentWithOptions( - ctx context.Context, - agent SessionAgent, - sessionID, agentMessageID, toolCallID string, - prompt string, - sessionTitle string, - sessionSetup func(sessionID string), -) (fantasy.ToolResponse, error) { +func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (fantasy.ToolResponse, error) { // Create sub-session - agentToolSessionID := c.sessions.CreateAgentToolSessionID(agentMessageID, toolCallID) - session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, sessionID, sessionTitle) + 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 sessionSetup != nil { - sessionSetup(session.ID) + if params.SessionSetup != nil { + params.SessionSetup(session.ID) } // Get model configuration - model := agent.Model() + model := params.Agent.Model() maxTokens := model.CatwalkCfg.DefaultMaxTokens if model.ModelCfg.MaxTokens != 0 { maxTokens = model.ModelCfg.MaxTokens @@ -989,9 +983,9 @@ func (c *coordinator) runSubAgentWithOptions( } // Run the agent - result, err := agent.Run(ctx, SessionAgentCall{ + result, err := params.Agent.Run(ctx, SessionAgentCall{ SessionID: session.ID, - Prompt: prompt, + Prompt: params.Prompt, MaxOutputTokens: maxTokens, ProviderOptions: getProviderOptions(model, providerCfg), Temperature: model.ModelCfg.Temperature, @@ -1005,7 +999,7 @@ func (c *coordinator) runSubAgentWithOptions( } // Update parent session cost - if err := c.updateParentSessionCost(ctx, session.ID, sessionID); err != nil { + if err := c.updateParentSessionCost(ctx, session.ID, params.SessionID); err != nil { return fantasy.ToolResponse{}, err } 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) + }) +} From 773efbf84f5bc426633cf7221621ce7af2120f3b Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 25 Feb 2026 11:46:49 -0500 Subject: [PATCH 23/51] fix(tools/view): perform UTF-8 validity check only if read succeeds Prior to this change the check would happen before we would know if the file read was successful. --- internal/agent/tools/view.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 0e56a4f6866d018efabfa952b7a10dc97507656f..77e51aa8e491c685a12aac9c9cf2235cebf0126f 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -189,8 +189,7 @@ func NewViewTool( 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 } From 41a931ebc9b3a4a96f7ec799142569fc1022f47d Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 25 Feb 2026 11:53:34 -0500 Subject: [PATCH 24/51] fix(mcp): restore handling for unsupported resources/list method --- internal/agent/tools/mcp/resources.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 12f0d9f512e01bde5d08d2c88e60fd0eb38eb891 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 26 Feb 2026 13:32:42 -0500 Subject: [PATCH 25/51] perf(tools/view): avoid scanning entire file to count lines --- internal/agent/tools/view.go | 40 ++++++++++++------------------------ 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 77e51aa8e491c685a12aac9c9cf2235cebf0126f..284ff2decf66ff30454f26277749616bd46bece5 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -185,7 +185,7 @@ 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) } @@ -195,11 +195,9 @@ func NewViewTool( notifyLSPs(ctx, lspManager, filePath) 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"))) } @@ -251,38 +249,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++ lineText := scanner.Text() if len(lineText) > MaxLineLength { lineText = lineText[:MaxLineLength] + "..." @@ -290,16 +278,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 to determine if the file has more content. + hasMore := 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) { From 6604dd0e38440369e98c7462e977bc2e015f292a Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 26 Feb 2026 13:45:47 -0500 Subject: [PATCH 26/51] perf(lsp): don't watch for changes when simply reading files --- internal/agent/tools/diagnostics.go | 26 +++++++++++++++++++++++--- internal/agent/tools/view.go | 2 +- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index 41a1b8abfa8e54c32de783cd2bf1da11f3bdf264..3adadd17f70584be221458dda8210f3e644ac4fd 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -37,16 +37,36 @@ 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) + } +} + +// 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/view.go b/internal/agent/tools/view.go index 284ff2decf66ff30454f26277749616bd46bece5..f304e9f7c45c195b6aef7d768b6671c1cdf74e82 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -193,7 +193,7 @@ func NewViewTool( return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil } - notifyLSPs(ctx, lspManager, filePath) + openInLSPs(ctx, lspManager, filePath) output := "\n" output += addLineNumbers(content, params.Offset+1) From 3c5fcff3855762e6d24cf19fd45d8d69253ee594 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 26 Feb 2026 13:52:36 -0500 Subject: [PATCH 27/51] perf(tools/view): pause briefly for LSP diagnostics when viewing a file --- internal/agent/tools/diagnostics.go | 21 +++++++++++++++++++++ internal/agent/tools/view.go | 2 ++ 2 files changed, 23 insertions(+) diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index 3adadd17f70584be221458dda8210f3e644ac4fd..06c2c1e813a61099be6aad26a988317df38b639b 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -59,6 +59,27 @@ func openInLSPs( } } +// 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 + } + + for client := range manager.Clients().Seq() { + if !client.HandlesFile(filepath) { + continue + } + client.WaitForDiagnostics(ctx, timeout) + } +} + // notifyLSPs notifies LSP servers that a file has changed and waits for // updated diagnostics. Use this after edit/multiedit operations. func notifyLSPs( diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index f304e9f7c45c195b6aef7d768b6671c1cdf74e82..7b73e2e1fb0f95b3d729bc3f5da72a99d362f520 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" @@ -194,6 +195,7 @@ func NewViewTool( } openInLSPs(ctx, lspManager, filePath) + waitForLSPDiagnostics(ctx, lspManager, filePath, 300*time.Millisecond) output := "\n" output += addLineNumbers(content, params.Offset+1) From ded666aa641e7b9cabb750fcb68441b9edff6a45 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 26 Feb 2026 14:15:17 -0500 Subject: [PATCH 28/51] chore(lint): don't shadow err vars --- internal/agent/tools/view.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 7b73e2e1fb0f95b3d729bc3f5da72a99d362f520..1b9e60546d10f5f287c8f6284c334843260f2051 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -98,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, @@ -109,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 @@ -176,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) From d98c854a148e5dce070c9d1d0a9b07749fbebaf5 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 26 Feb 2026 14:29:01 -0500 Subject: [PATCH 29/51] perf(lsp): use shared timeout for parallel diagnostics collection --- internal/agent/tools/diagnostics.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index 06c2c1e813a61099be6aad26a988317df38b639b..bce1661151e12a78c1fc969077d70bfb4015fbc6 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" @@ -72,12 +73,21 @@ func waitForLSPDiagnostics( return } + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var wg sync.WaitGroup for client := range manager.Clients().Seq() { if !client.HandlesFile(filepath) { continue } - client.WaitForDiagnostics(ctx, timeout) + wg.Add(1) + go func() { + defer wg.Done() + client.WaitForDiagnostics(waitCtx, timeout) + }() } + wg.Wait() } // notifyLSPs notifies LSP servers that a file has changed and waits for From 058322169d3c1b3e1ffad43da8bb7c00c94e706d Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 26 Feb 2026 14:38:23 -0500 Subject: [PATCH 30/51] fix(tools/view): fix view paging, test for edge cases --- internal/agent/tools/view.go | 6 +-- internal/agent/tools/view_test.go | 87 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 internal/agent/tools/view_test.go diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 1b9e60546d10f5f287c8f6284c334843260f2051..9b856fd1fad961a2aabf491c0f8aac6914965e2e 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -272,7 +272,7 @@ func readTextFile(filePath string, offset, limit int) (string, bool, error) { // Pre-allocate slice with expected capacity. lines := make([]string, 0, limit) - for scanner.Scan() && len(lines) < limit { + for len(lines) < limit && scanner.Scan() { lineText := scanner.Text() if len(lineText) > MaxLineLength { lineText = lineText[:MaxLineLength] + "..." @@ -280,8 +280,8 @@ func readTextFile(filePath string, offset, limit int) (string, bool, error) { lines = append(lines, lineText) } - // Peek one more line to determine if the file has more content. - hasMore := scanner.Scan() + // Peek one more line only when we filled the limit. + hasMore := len(lines) == limit && scanner.Scan() if err := scanner.Err(); err != nil { return "", false, err 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) +} From d3682ac4719bd5892ceb21c15c66009c1e0b9b54 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 26 Feb 2026 14:41:28 -0500 Subject: [PATCH 31/51] use new wg pattern --- internal/agent/tools/diagnostics.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index bce1661151e12a78c1fc969077d70bfb4015fbc6..9697cefb049c3184cff13fe9c3de4ce6dd12dfb6 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -81,11 +81,9 @@ func waitForLSPDiagnostics( if !client.HandlesFile(filepath) { continue } - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { client.WaitForDiagnostics(waitCtx, timeout) - }() + }) } wg.Wait() } From 02ec5db4742886c1e81339c6fc18fa234a49c7c8 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 26 Feb 2026 15:40:49 -0500 Subject: [PATCH 32/51] bugfix: find references, double timeout --- internal/agent/tools/diagnostics.go | 5 +---- internal/agent/tools/references.go | 6 +++++- internal/lsp/client.go | 5 +++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index 9697cefb049c3184cff13fe9c3de4ce6dd12dfb6..79b67be95d3312af47a7d5bc1765cc320a9894d1 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -73,16 +73,13 @@ func waitForLSPDiagnostics( return } - waitCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - var wg sync.WaitGroup for client := range manager.Clients().Seq() { if !client.HandlesFile(filepath) { continue } wg.Go(func() { - client.WaitForDiagnostics(waitCtx, timeout) + client.WaitForDiagnostics(ctx, timeout) }) } wg.Wait() diff --git a/internal/agent/tools/references.go b/internal/agent/tools/references.go index c544886b9de3e60ef6932cbc2932fc0a0ab639f0..e8547264e6725a9062bf767e42f842595a2638dd 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 { diff --git a/internal/lsp/client.go b/internal/lsp/client.go index bc8d31dba0360aed813d4b9cf6e5b769bb2559d4..ed2ea2633cf3482010ad32a8f2b06aeacb69b243 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -533,6 +533,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) From 20e8aea8e6bf617c1bc9bdce6ab487bf10c129d7 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sat, 28 Feb 2026 04:36:52 -0300 Subject: [PATCH 34/51] chore(legal): @detro has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 2c5339654d92310286dc767bdb564975cb611a70..362b48f91706441437b2ff5291e2ab0c415b1b5f 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1303,6 +1303,14 @@ "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 } ] } \ No newline at end of file From 57744caf57c37028045be35bd763c335daaf9121 Mon Sep 17 00:00:00 2001 From: Ivan De Marino Date: Sat, 28 Feb 2026 10:07:58 +0000 Subject: [PATCH 35/51] chore(deps): bump charm.land/catwalk from v0.24.0 to v0.25.0 (#2326) Catwalk v0.25.0 release: https://github.com/charmbracelet/catwalk/releases/tag/v0.25.0 This updates `vertexai.json` provider definition to include `gemini-3.1-*` and `claude-*-4.6`. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c9d25b9bd00de5494cb54e3b2facb8064469a9fd..bff64c97c5ae0f28ea8669ceb3869e01593d7faf 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.0 - charm.land/catwalk v0.24.0 + charm.land/catwalk v0.25.0 charm.land/fantasy v0.11.0 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b charm.land/lipgloss/v2 v2.0.0 diff --git a/go.sum b/go.sum index 3039b4b7cc55c0a8cd6f1b8727b8f0319f0c1f78..af54c21646684c9d689143c233ac4029abef4875 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ 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.24.0 h1:uR+gXIjJTg5jF5Fg9W8fmPOQb9rGPWlczIP2NVZEIlI= -charm.land/catwalk v0.24.0/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= +charm.land/catwalk v0.25.0 h1:bRkP8NPm3Tc+R89yVmaAQVk1jtyWxENJRu6BXwkCo8I= +charm.land/catwalk v0.25.0/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= From 42a9e75543f4c7604856e464f40b5c0fb9090e80 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sun, 1 Mar 2026 01:22:09 -0300 Subject: [PATCH 36/51] chore(legal): @taoeffect has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 362b48f91706441437b2ff5291e2ab0c415b1b5f..81935cd960f28172dc14b6bd11bf27759568a5fa 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1311,6 +1311,14 @@ "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 } ] } \ No newline at end of file From 2baf565dc315870f0d77ac1b71a9431d126f2285 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:51:13 -0300 Subject: [PATCH 37/51] chore(legal): @vmfu has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 81935cd960f28172dc14b6bd11bf27759568a5fa..c7037135e5d00cb257235b8574d145dec774b711 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1319,6 +1319,14 @@ "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 From c843441f1e9b66ae4242925f81ccb135074ed0f3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:36:43 -0300 Subject: [PATCH 38/51] chore(deps): bump the all group with 4 updates (#2341) Bumps the all group with 4 updates: [github.com/charmbracelet/x/powernap](https://github.com/charmbracelet/x), [github.com/go-git/go-git/v5](https://github.com/go-git/go-git), [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) and [golang.org/x/net](https://github.com/golang/net). Updates `github.com/charmbracelet/x/powernap` from 0.1.0 to 0.1.1 - [Commits](https://github.com/charmbracelet/x/compare/v0.1.0...vcr/v0.1.1) Updates `github.com/go-git/go-git/v5` from 5.16.5 to 5.17.0 - [Release notes](https://github.com/go-git/go-git/releases) - [Commits](https://github.com/go-git/go-git/compare/v5.16.5...v5.17.0) Updates `github.com/modelcontextprotocol/go-sdk` from 1.3.1 to 1.4.0 - [Release notes](https://github.com/modelcontextprotocol/go-sdk/releases) - [Commits](https://github.com/modelcontextprotocol/go-sdk/compare/v1.3.1...v1.4.0) Updates `golang.org/x/net` from 0.50.0 to 0.51.0 - [Commits](https://github.com/golang/net/compare/v0.50.0...v0.51.0) --- updated-dependencies: - dependency-name: github.com/charmbracelet/x/powernap dependency-version: 0.1.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: github.com/go-git/go-git/v5 dependency-version: 5.17.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: github.com/modelcontextprotocol/go-sdk dependency-version: 1.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: golang.org/x/net dependency-version: 0.51.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 11 +++++------ go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index bff64c97c5ae0f28ea8669ceb3869e01593d7faf..42178f14ed96375e6a1f73024bbf848d035c8050 100644 --- a/go.mod +++ b/go.mod @@ -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.1 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 @@ -109,7 +109,7 @@ require ( 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 diff --git a/go.sum b/go.sum index af54c21646684c9d689143c233ac4029abef4875..bfba1de43d24691cd7244c295864721ef6442d3c 100644 --- a/go.sum +++ b/go.sum @@ -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.1 h1:wyihBq9H/ZZAAQeoR8uWpJWvXQwWmJWMBEL7VmDPV5A= +github.com/charmbracelet/x/powernap v0.1.1/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= @@ -165,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= @@ -264,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= @@ -425,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= From 4596b02f5617c29fd7e8a3768c49be71fd68098b Mon Sep 17 00:00:00 2001 From: huaiyuWangh <34158348+huaiyuWangh@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:40:54 +0800 Subject: [PATCH 39/51] fix(lsp): treat adjacent ranges as non-overlapping per LSP spec (#2322) Fix rangesOverlap() to treat LSP ranges as half-open intervals [start, end) per the specification. Adjacent edits where one range ends where another begins are no longer incorrectly rejected as overlapping. --- internal/lsp/util/edit.go | 8 +++- internal/lsp/util/edit_test.go | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 internal/lsp/util/edit_test.go diff --git a/internal/lsp/util/edit.go b/internal/lsp/util/edit.go index 8b500ac67489e5fbcd0981a012dcf7a0c871f67e..23e9d479f2223773bbd0db41cc049bf8a02f3357 100644 --- a/internal/lsp/util/edit.go +++ b/internal/lsp/util/edit.go @@ -247,14 +247,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..cb32d3498289dbf4cdb69dfc9ca4ee73f10b7a20 --- /dev/null +++ b/internal/lsp/util/edit_test.go @@ -0,0 +1,68 @@ +package util + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" +) + +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") + }) + } +} From 8762efc8fa0970883f807a5f564b5ef8b6042e4c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 2 Mar 2026 15:02:40 -0500 Subject: [PATCH 40/51] fix(ui): follow scroll when at bottom (#2336) * fix(ui): follow scroll when at bottom This change attempt to completely fix the agent scroll issue when at bottom. It should follow the agent when the follow flag is set. * fix(ui): make sure we select the last item when at bottom --- internal/ui/model/ui.go | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 8ee3a36ad680ee12c2087dcc64eda6c3b6675215..a19788d7bfa5b33287a604871e1fe6b7e43f2d84 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -556,6 +556,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() { @@ -680,7 +685,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) } @@ -692,6 +701,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() { @@ -822,7 +836,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. @@ -950,7 +964,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) { @@ -1029,16 +1043,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 @@ -1118,13 +1132,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 { @@ -1804,7 +1819,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. From da2eef2a60fc26432d3c71d0e6d7c1382f663a23 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:12:43 -0300 Subject: [PATCH 42/51] chore(deps): bump actions/setup-go from 6.2.0 to 6.3.0 in the all group (#2340) Bumps the all group with 1 update: [actions/setup-go](https://github.com/actions/setup-go). Updates `actions/setup-go` from 6.2.0 to 6.3.0 - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5...4b73464bb391d4059bd26b0524d20df3927bd417) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- .github/workflows/schema-update.yml | 2 +- .github/workflows/security.yml | 2 +- .github/workflows/snapshot.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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 From 9ec46b8d439646a505018576e7197b2daacfb7b3 Mon Sep 17 00:00:00 2001 From: Sean Porter Date: Mon, 2 Mar 2026 12:22:53 -0800 Subject: [PATCH 43/51] feat(shell): add blocking wait option to job_output tool (#2189) --- internal/agent/common_test.go | 9 +++------ internal/agent/tools/job_output.go | 5 +++++ internal/agent/tools/job_output.md | 3 +++ internal/shell/background.go | 9 +++++++++ internal/shell/background_test.go | 25 +++++++++++++++++++++++++ 5 files changed, 45 insertions(+), 6 deletions(-) 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/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/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)) +} From aa0997b31590d4ac61221f6d6f35eef2de50628f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 4 Mar 2026 11:07:06 +0300 Subject: [PATCH 44/51] chore: bump powernap to v0.1.2 Related: https://github.com/charmbracelet/crush/pull/2349 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 42178f14ed96375e6a1f73024bbf848d035c8050..41ecf018a39b4169ee116394d8fe61bcf80fe4f0 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ 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.1 + github.com/charmbracelet/x/powernap v0.1.2 github.com/charmbracelet/x/term v0.2.2 github.com/clipperhouse/displaywidth v0.11.0 github.com/clipperhouse/uax29/v2 v2.7.0 diff --git a/go.sum b/go.sum index bfba1de43d24691cd7244c295864721ef6442d3c..4add2c496f5a0980907c492dc7f1dec0139d2a8e 100644 --- a/go.sum +++ b/go.sum @@ -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.1 h1:wyihBq9H/ZZAAQeoR8uWpJWvXQwWmJWMBEL7VmDPV5A= -github.com/charmbracelet/x/powernap v0.1.1/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= +github.com/charmbracelet/x/powernap v0.1.2 h1:5i0zWrqE5c9PY+H90DXg/5+jHNZ5FgXOyOU4dzOSIxs= +github.com/charmbracelet/x/powernap v0.1.2/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= From 56d79d108ee5036224ef13155871457425c7729e Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Wed, 4 Mar 2026 03:07:46 -0500 Subject: [PATCH 45/51] fix(lsp): fallback to Kill() on timeout (#2349) --- internal/lsp/client.go | 31 +++++++++++++++++++++++++------ internal/lsp/manager.go | 4 ++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index ed2ea2633cf3482010ad32a8f2b06aeacb69b243..973433801d586f2dc93d6156270308bda2e3d31f 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() - return c.client.Exit() + 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() + }() + + 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. diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index 5e238fda296a5e28034482a3a0b163ae1ae04d6c..13a78cef2a471a71c1e741e32e08e8d7edcb7484 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -205,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 } @@ -228,7 +228,7 @@ 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 } From 6d89de6320761c6eb8791cd7e87c3812a6ee5ce0 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 4 Mar 2026 11:31:33 -0300 Subject: [PATCH 46/51] chore: update catwalk --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 41ecf018a39b4169ee116394d8fe61bcf80fe4f0..b708ffa91080bdfc7e13658a8069f1e706e8b477 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.0 - charm.land/catwalk v0.25.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 diff --git a/go.sum b/go.sum index 4add2c496f5a0980907c492dc7f1dec0139d2a8e..9f58e3288eb0c39fd3a80e09601c85a2be9ec643 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ 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.25.0 h1:bRkP8NPm3Tc+R89yVmaAQVk1jtyWxENJRu6BXwkCo8I= -charm.land/catwalk v0.25.0/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= +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= From 5b2e5befe670517b951efe4735c7159fcb92126a Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 4 Mar 2026 16:19:51 -0300 Subject: [PATCH 48/51] ci: add hyper to labeler --- .github/labeler.yml | 2 ++ 1 file changed, 2 insertions(+) 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": From ae1bac33d20768c95327120fd7d11eaf4dd29f73 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 4 Mar 2026 16:22:44 -0300 Subject: [PATCH 49/51] chore: update hyper (#2354) --- internal/agent/hyper/provider.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 8a9000b6bd23f0451f953282c5c59a91add8c0c8 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 4 Mar 2026 18:33:12 -0300 Subject: [PATCH 50/51] ci: update golangci-lint to v2.10 and fix new issues (#2355) --- .github/workflows/lint.yml | 2 +- internal/agent/tools/references.go | 6 +++--- internal/agent/tools/search.go | 8 ++++---- internal/agent/tools/sourcegraph.go | 16 ++++++++-------- internal/agent/tools/web_fetch.go | 6 +++--- internal/ui/chat/tools.go | 4 ++-- 6 files changed, 21 insertions(+), 21 deletions(-) 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/internal/agent/tools/references.go b/internal/agent/tools/references.go index e8547264e6725a9062bf767e42f842595a2638dd..aef683eb9dfee44b92e96cd3cb88b8654eb03b2a 100644 --- a/internal/agent/tools/references.go +++ b/internal/agent/tools/references.go @@ -176,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/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/ui/chat/tools.go b/internal/ui/chat/tools.go index 17c15911e377318d4c4a1515fca57332f31996e1..be82a6e197b0505d83abe0c8d679b0469ed6b93a 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -1337,7 +1337,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```") @@ -1354,7 +1354,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() From fae0f2e82da57a0e0335d86b417a819121f4e69f Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 4 Mar 2026 15:09:01 -0700 Subject: [PATCH 51/51] fix(lsp/edit): properly handle non-ascii chars (e.g. CJK) (#2325) --- go.mod | 2 +- go.sum | 4 +- internal/lsp/client.go | 2 +- internal/lsp/handlers.go | 25 +-- internal/lsp/util/edit.go | 61 +++++-- internal/lsp/util/edit_test.go | 312 ++++++++++++++++++++++++++++++++- 6 files changed, 377 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index b708ffa91080bdfc7e13658a8069f1e706e8b477..26fcbaf6e17fe9032e8c0f063eb69a360ca70734 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ 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.2 + 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 diff --git a/go.sum b/go.sum index 9f58e3288eb0c39fd3a80e09601c85a2be9ec643..d759ae75ee70b1266f6a270d6e4784040afe348d 100644 --- a/go.sum +++ b/go.sum @@ -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.2 h1:5i0zWrqE5c9PY+H90DXg/5+jHNZ5FgXOyOU4dzOSIxs= -github.com/charmbracelet/x/powernap v0.1.2/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= diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 973433801d586f2dc93d6156270308bda2e3d31f..2aa1b49a781489598f865cfd067d1adf5d68b7aa 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -194,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) { diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 63ad93b2dd1cbc856eda4a9e41884dfc27e93870..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 diff --git a/internal/lsp/util/edit.go b/internal/lsp/util/edit.go index 23e9d479f2223773bbd0db41cc049bf8a02f3357..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) } } diff --git a/internal/lsp/util/edit_test.go b/internal/lsp/util/edit_test.go index cb32d3498289dbf4cdb69dfc9ca4ee73f10b7a20..8e7d12fc9cffedffb8b701f0a8c8040f162327fa 100644 --- a/internal/lsp/util/edit_test.go +++ b/internal/lsp/util/edit_test.go @@ -3,11 +3,319 @@ package util import ( "testing" - "github.com/stretchr/testify/require" - + 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()