Detailed changes
@@ -14,6 +14,7 @@ on:
permissions:
issues: write
+ pull-requests: write
contents: read
jobs:
@@ -26,5 +27,4 @@ jobs:
enable-versioned-regex: 0
include-title: 1
include-body: 0
- repo-token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
issue-number: ${{ github.event.inputs.issue-number || github.event.issue.number || github.event.pull_request.number }}
@@ -5,9 +5,9 @@ go 1.26.3
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.6
- charm.land/catwalk v0.41.8
+ charm.land/catwalk v0.43.0
charm.land/fang/v2 v2.0.1
- charm.land/fantasy v0.25.2
+ charm.land/fantasy v0.26.0
charm.land/glamour/v2 v2.0.0
charm.land/lipgloss/v2 v2.0.3
charm.land/log/v2 v2.0.0
@@ -90,8 +90,8 @@ require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
- github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect
- github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
+ github.com/aws/aws-sdk-go-v2/config v1.32.18 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.17 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
@@ -100,7 +100,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
@@ -137,7 +137,7 @@ require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/s2a-go v0.1.9 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
@@ -197,7 +197,7 @@ require (
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
- golang.org/x/crypto v0.51.0 // indirect
+ golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/mod v0.35.0 // indirect
@@ -205,9 +205,9 @@ require (
golang.org/x/term v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect
- google.golang.org/api v0.279.0 // indirect
- google.golang.org/genai v1.57.0 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
+ google.golang.org/api v0.280.0 // indirect
+ google.golang.org/genai v1.58.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
@@ -2,12 +2,12 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
-charm.land/catwalk v0.41.8 h1:SxM6KyFD5jtBF2lZZKk6cYCbw1GVlNfn05ZSRtynxEE=
-charm.land/catwalk v0.41.8/go.mod h1:dtK2+UfdsFJgIriRPodMsSJw0XefrFOq6fdvuS57v3s=
+charm.land/catwalk v0.43.0 h1:BB5UTfnidXWDj9McAuEIW3tHuH52Yye6rPCs5yAxrcc=
+charm.land/catwalk v0.43.0/go.mod h1:dtK2+UfdsFJgIriRPodMsSJw0XefrFOq6fdvuS57v3s=
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
-charm.land/fantasy v0.25.2 h1:K7ZOM3UEay//NHfiFAeIMRaOqhspxe0UyccIJOYrjuo=
-charm.land/fantasy v0.25.2/go.mod h1:9ykD5gjn8BCjpZqA66vet7H1KsmR+kP0Q0qw1FiqCk0=
+charm.land/fantasy v0.26.0 h1:u0DtmlrjgnImuNVxKitussEEurMPF1g96Ij7kplNp7w=
+charm.land/fantasy v0.26.0/go.mod h1:UBF6zhmGRCgUpc+nRj8Thw0YD2DOcsVFf/ifEu9j//U=
charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
@@ -60,10 +60,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6t
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
-github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
-github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
+github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q=
+github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
@@ -80,8 +80,8 @@ github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VX
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
@@ -233,8 +233,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
-github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
+github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
+github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -463,8 +463,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
-golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -555,16 +555,16 @@ golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74=
-google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
-google.golang.org/genai v1.57.0 h1:qTyG2ynz5dQy2jF4CvZdLHHVslhR0heMue+zM1a4GNM=
-google.golang.org/genai v1.57.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
-google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554=
-google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM=
-google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc=
-google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk=
+google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU=
+google.golang.org/genai v1.58.0 h1:MNA3ZkRyr7MnRwZ9RNZ60p4+UMKV3yYRw6pyHq4pp0U=
+google.golang.org/genai v1.58.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
+google.golang.org/genproto v0.0.0-20260523011958-0a33c5d7ca68 h1:cTHF8xtqtBN5sQ4dcoNwOS6FFejvFTkWQbZXsTU3trM=
+google.golang.org/genproto v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:RRHjglSYABVCWpQ7USCpdfhcd9t4PkajvVwyynZizTc=
+google.golang.org/genproto/googleapis/api v0.0.0-20260523011958-0a33c5d7ca68 h1:WVVw1Nl19li0fMX++FJ3ye1z9+S1N35QODDy5qpnaXw=
+google.golang.org/genproto/googleapis/api v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@@ -806,7 +806,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str
return azure.New(opts...)
}
-func (c *coordinator) buildBedrockProvider(apiKey string, headers map[string]string) (fantasy.Provider, error) {
+func (c *coordinator) buildBedrockProvider(apiKey string, headers map[string]string, providerID string) (fantasy.Provider, error) {
var opts []bedrock.Option
if c.cfg.Config().Options.Debug {
httpClient := log.NewHTTPClient()
@@ -815,6 +815,7 @@ func (c *coordinator) buildBedrockProvider(apiKey string, headers map[string]str
if len(headers) > 0 {
opts = append(opts, bedrock.WithHeaders(headers))
}
+
switch {
case apiKey != "":
opts = append(opts, bedrock.WithAPIKey(apiKey))
@@ -823,6 +824,14 @@ func (c *coordinator) buildBedrockProvider(apiKey string, headers map[string]str
default:
// Skip, let the SDK do authentication.
}
+
+ switch providerID {
+ case string(catwalk.InferenceProviderBedrockEurope):
+ opts = append(opts, bedrock.WithRegion("eu-west-1"))
+ default:
+ opts = append(opts, bedrock.WithRegion("us-east-1"))
+ }
+
return bedrock.New(opts...)
}
@@ -897,7 +906,7 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
case azure.Name:
return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
case bedrock.Name:
- return c.buildBedrockProvider(apiKey, headers)
+ return c.buildBedrockProvider(apiKey, headers, providerCfg.ID)
case google.Name:
return c.buildGoogleProvider(baseURL, apiKey, headers)
case "google-vertex":
@@ -9,9 +9,9 @@
{
"id": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash",
- "cost_per_1m_in": 0.146,
- "cost_per_1m_out": 0.294,
- "cost_per_1m_in_cached": 0.073,
+ "cost_per_1m_in": 0.14,
+ "cost_per_1m_out": 0.28,
+ "cost_per_1m_in_cached": 0.07,
"cost_per_1m_out_cached": 0,
"context_window": 1048576,
"default_max_tokens": 104857,
@@ -21,9 +21,9 @@
{
"id": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro",
- "cost_per_1m_in": 1.788,
- "cost_per_1m_out": 3.62,
- "cost_per_1m_in_cached": 0.894,
+ "cost_per_1m_in": 1.7562,
+ "cost_per_1m_out": 3.5524,
+ "cost_per_1m_in_cached": 0.8781,
"cost_per_1m_out_cached": 0,
"context_window": 1048576,
"default_max_tokens": 60000,
@@ -33,9 +33,9 @@
{
"id": "gemma-4-26b-a4b-it",
"name": "Gemma 4 26B A4B",
- "cost_per_1m_in": 0.1225,
- "cost_per_1m_out": 0.428,
- "cost_per_1m_in_cached": 0.06125,
+ "cost_per_1m_in": 0.102,
+ "cost_per_1m_out": 0.394,
+ "cost_per_1m_in_cached": 0.051,
"cost_per_1m_out_cached": 0,
"context_window": 256000,
"default_max_tokens": 25600,
@@ -57,21 +57,21 @@
{
"id": "glm-5.1",
"name": "GLM-5.1",
- "cost_per_1m_in": 1.33,
- "cost_per_1m_out": 4.22,
- "cost_per_1m_in_cached": 0.665,
+ "cost_per_1m_in": 1.31,
+ "cost_per_1m_out": 4.2,
+ "cost_per_1m_in_cached": 0.655,
"cost_per_1m_out_cached": 0,
"context_window": 202750,
- "default_max_tokens": 1638,
+ "default_max_tokens": 3276,
"can_reason": true,
"supports_attachments": false
},
{
"id": "gpt-oss-120b",
"name": "gpt-oss-120b",
- "cost_per_1m_in": 0.162,
- "cost_per_1m_out": 0.69,
- "cost_per_1m_in_cached": 0.081,
+ "cost_per_1m_in": 0.166,
+ "cost_per_1m_out": 0.632,
+ "cost_per_1m_in_cached": 0.083,
"cost_per_1m_out_cached": 0,
"context_window": 131072,
"default_max_tokens": 13107,
@@ -87,9 +87,9 @@
{
"id": "kimi-k2.5",
"name": "Kimi K2.5",
- "cost_per_1m_in": 0.562,
- "cost_per_1m_out": 2.91,
- "cost_per_1m_in_cached": 0.281,
+ "cost_per_1m_in": 0.552,
+ "cost_per_1m_out": 2.84,
+ "cost_per_1m_in_cached": 0.276,
"cost_per_1m_out_cached": 0,
"context_window": 262144,
"default_max_tokens": 26214,
@@ -99,9 +99,9 @@
{
"id": "kimi-k2.6",
"name": "Kimi K2.6",
- "cost_per_1m_in": 1,
- "cost_per_1m_out": 4.1,
- "cost_per_1m_in_cached": 0.5,
+ "cost_per_1m_in": 0.978,
+ "cost_per_1m_out": 4.12,
+ "cost_per_1m_in_cached": 0.489,
"cost_per_1m_out_cached": 0,
"context_window": 262142,
"default_max_tokens": 26214,
@@ -111,9 +111,9 @@
{
"id": "llama-3.3-70b-instruct",
"name": "Llama 3.3 70B Instruct",
- "cost_per_1m_in": 0.638,
- "cost_per_1m_out": 0.768,
- "cost_per_1m_in_cached": 0.319,
+ "cost_per_1m_in": 0.614,
+ "cost_per_1m_out": 0.864,
+ "cost_per_1m_in_cached": 0.307,
"cost_per_1m_out_cached": 0,
"context_window": 128000,
"default_max_tokens": 12800,
@@ -135,9 +135,9 @@
{
"id": "minimax-m2.7",
"name": "MiniMax M2.7",
- "cost_per_1m_in": 0.4158,
+ "cost_per_1m_in": 0.42,
"cost_per_1m_out": 1.68,
- "cost_per_1m_in_cached": 0.2079,
+ "cost_per_1m_in_cached": 0.21,
"cost_per_1m_out_cached": 0,
"context_window": 204800,
"default_max_tokens": 20480,
@@ -195,9 +195,9 @@
{
"id": "qwen3-coder-480b-a35b-instruct-int4-mixed-ar",
"name": "Qwen3 Coder 480B A35B Instruct INT4 Mixed AR",
- "cost_per_1m_in": 0.746,
- "cost_per_1m_out": 2.13,
- "cost_per_1m_in_cached": 0.373,
+ "cost_per_1m_in": 0.769,
+ "cost_per_1m_out": 2.235,
+ "cost_per_1m_in_cached": 0.3845,
"cost_per_1m_out_cached": 0,
"context_window": 106000,
"default_max_tokens": 10600,
@@ -207,9 +207,9 @@
{
"id": "qwen3-next-80b-a3b-instruct",
"name": "Qwen3 Next 80B A3B Instruct",
- "cost_per_1m_in": 0.128,
- "cost_per_1m_out": 1.28,
- "cost_per_1m_in_cached": 0.064,
+ "cost_per_1m_in": 0.1175,
+ "cost_per_1m_out": 1.136,
+ "cost_per_1m_in_cached": 0.05875,
"cost_per_1m_out_cached": 0,
"context_window": 262144,
"default_max_tokens": 26214,
@@ -278,7 +278,8 @@ type Options struct {
InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
- DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
+ DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Deprecated: Use notification_style instead. Disable desktop notifications,default=false"`
+ NotificationStyle string `json:"notification_style,omitempty" jsonschema:"description=Notification style to use. Options: auto (default), native, osc, bell, disabled. Auto selects based on environment: native for local sessions, osc for SSH (with automatic OSC 99/777 detection).,enum=auto,enum=native,enum=osc,enum=bell,enum=disabled,default=auto"`
DisabledSkills []string `json:"disabled_skills,omitempty" jsonschema:"description=List of skill names to disable and hide from the agent,example=crush-config"`
}
@@ -26,6 +26,8 @@ import (
"github.com/charmbracelet/crush/internal/home"
powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
"github.com/qjebbs/go-jsons"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
)
const defaultCatwalkURL = "https://catwalk.charm.land"
@@ -33,6 +35,9 @@ const defaultCatwalkURL = "https://catwalk.charm.land"
// Load loads the configuration from the default paths and returns a
// ConfigStore that owns both the pure-data Config and all runtime state.
func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
+ // Migrate deprecated disable_notifications before loading config.
+ migrateDisableNotifications()
+
configPaths := lookupConfigs(workingDir)
cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
@@ -479,6 +484,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
}
}
+
c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
}
@@ -811,6 +817,69 @@ func hasAWSCredentials(env env.Env) bool {
return false
}
+// migrateDisableNotifications migrates the deprecated disable_notifications
+// field to notification_style. It checks both the user config (~/.config) and
+// data config (~/.local) files. If disable_notifications is true, it sets
+// notification_style to "disabled" in the data file. Regardless of value, it
+// removes disable_notifications from any file that contains it.
+func migrateDisableNotifications() {
+ globalConfig := GlobalConfig()
+ dataConfig := GlobalConfigData()
+
+ var wasDisabled bool
+ filesToClean := []string{}
+
+ for _, path := range []string{globalConfig, dataConfig} {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ continue
+ }
+ if gjson.Get(string(data), "options.disable_notifications").Exists() {
+ filesToClean = append(filesToClean, path)
+ if gjson.Get(string(data), "options.disable_notifications").Bool() {
+ wasDisabled = true
+ }
+ }
+ }
+
+ if len(filesToClean) == 0 {
+ return
+ }
+
+ // If notifications were disabled, persist the equivalent notification_style.
+ if wasDisabled {
+ data, err := os.ReadFile(dataConfig)
+ if err == nil {
+ if !gjson.Get(string(data), "options.notification_style").Exists() {
+ updated, err := sjson.Set(string(data), "options.notification_style", "disabled")
+ if err == nil {
+ if err := atomicWriteFile(dataConfig, []byte(updated), 0o600); err != nil {
+ slog.Warn("Failed to migrate disable_notifications to notification_style", "error", err)
+ } else {
+ slog.Info("Migrated disable_notifications: true to notification_style: disabled")
+ }
+ }
+ }
+ }
+ }
+
+ // Remove disable_notifications from all files that contain it.
+ for _, path := range filesToClean {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ continue
+ }
+ updated, err := sjson.Delete(string(data), "options.disable_notifications")
+ if err != nil {
+ slog.Warn("Failed to remove deprecated disable_notifications field", "path", path, "error", err)
+ continue
+ }
+ if err := atomicWriteFile(path, []byte(updated), 0o600); err != nil {
+ slog.Warn("Failed to write migrated config", "path", path, "error", err)
+ }
+ }
+}
+
// GlobalConfig returns the global configuration file path for the application.
func GlobalConfig() string {
if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
@@ -649,6 +649,9 @@ func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
s.reloadInProgress = false
}()
+ // Migrate deprecated disable_notifications before reloading config.
+ migrateDisableNotifications()
+
configPaths := lookupConfigs(s.workingDir)
cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
if err != nil {
@@ -9,6 +9,8 @@ import (
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
xstrings "github.com/charmbracelet/x/exp/strings"
+
+ "github.com/charmbracelet/crush/internal/ui/notification"
)
// Capabilities define different terminal capabilities supported.
@@ -35,6 +37,8 @@ type Capabilities struct {
TerminalVersion string
// ReportFocusEvents indicates whether the terminal supports focus events.
ReportFocusEvents bool
+ // OSC99Notifications indicates whether the terminal supports OSC 99 notifications.
+ OSC99Notifications bool
}
// Update updates the capabilities based on the given message.
@@ -63,6 +67,10 @@ func (c *Capabilities) Update(msg any) {
case ansi.ModeFocusEvent:
c.ReportFocusEvents = modeSupported(m.Value)
}
+ case uv.UnknownOscEvent:
+ if notification.DetectOSC99Support(string(m)) {
+ c.OSC99Notifications = true
+ }
}
}
@@ -72,12 +80,13 @@ func QueryCmd(env uv.Environ) tea.Cmd {
var sb strings.Builder
sb.WriteString(ansi.RequestPrimaryDeviceAttributes)
sb.WriteString(ansi.QueryModifyOtherKeys)
+ sb.WriteString(ansi.RequestModeFocusEvent)
+ sb.WriteString(notification.OSC99QuerySequence())
// Queries that should only be sent to "smart" normal terminals.
shouldQueryFor := shouldQueryCapabilities(env)
if shouldQueryFor {
sb.WriteString(ansi.RequestNameVersion)
- sb.WriteString(ansi.RequestModeFocusEvent)
sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
if _, isTmux := env.LookupEnv("TMUX"); isTmux {
@@ -45,14 +45,17 @@ type ActionSelectModel struct {
// Messages for commands
type (
- ActionNewSession struct{}
- ActionToggleHelp struct{}
- ActionToggleCompactMode struct{}
- ActionToggleThinking struct{}
- ActionTogglePills struct{}
- ActionExternalEditor struct{}
- ActionToggleYoloMode struct{}
- ActionToggleNotifications struct{}
+ ActionNewSession struct{}
+ ActionToggleHelp struct{}
+ ActionToggleCompactMode struct{}
+ ActionToggleThinking struct{}
+ ActionTogglePills struct{}
+ ActionExternalEditor struct{}
+ ActionToggleYoloMode struct{}
+ ActionToggleNotifications struct{}
+ ActionSelectNotificationStyle struct {
+ Style string
+ }
ActionToggleTransparentBackground struct{}
ActionInitializeProject struct{}
ActionSummarize struct {
@@ -511,14 +511,9 @@ func (c *Commands) defaultCommands() []*CommandItem {
commands = append(commands, NewCommandItem(c.com.Styles, "toggle_pills", label, "ctrl+t", ActionTogglePills{}))
}
- // Add a command for toggling notifications.
- cfg = c.com.Config()
- notificationsDisabled := cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications
- notificationLabel := "Disable Notifications"
- if notificationsDisabled {
- notificationLabel = "Enable Notifications"
- }
- commands = append(commands, NewCommandItem(c.com.Styles, "toggle_notifications", notificationLabel, "", ActionToggleNotifications{}))
+ // Add a command for selecting notification style via picker dialog.
+ notificationLabel := "Notification Style"
+ commands = append(commands, NewCommandItem(c.com.Styles, "select_notifications", notificationLabel, "", ActionOpenDialog{DialogID: NotificationsID}))
commands = append(
commands,
@@ -0,0 +1,310 @@
+package dialog
+
+import (
+ "charm.land/bubbles/v2/help"
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/list"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/sahilm/fuzzy"
+)
+
+const (
+ // NotificationsID is the identifier for the notification style picker dialog.
+ NotificationsID = "notifications"
+ notificationsDialogMaxWidth = 50
+ notificationsDialogMaxHeight = 12
+)
+
+// NotificationStyle represents a notification backend option.
+type NotificationStyle struct {
+ ID string
+ Title string
+ Description string
+}
+
+// AllNotificationStyles lists all available notification styles in order.
+var AllNotificationStyles = []NotificationStyle{
+ {ID: "auto", Title: "Auto", Description: "Automatically detect the best backend"},
+ {ID: "native", Title: "Native", Description: "Use system notifications (macOS/Linux/Windows)"},
+ {ID: "osc", Title: "OSC", Description: "Use terminal OSC escape sequences"},
+ {ID: "bell", Title: "Bell", Description: "Use terminal bell character"},
+ {ID: "disabled", Title: "Disabled", Description: "Turn off notifications"},
+}
+
+// Notifications represents a dialog for selecting notification style.
+type Notifications struct {
+ com *common.Common
+ help help.Model
+ list *list.FilterableList
+ input textinput.Model
+
+ keyMap struct {
+ Select key.Binding
+ Next key.Binding
+ Previous key.Binding
+ UpDown key.Binding
+ Close key.Binding
+ }
+}
+
+// NotificationItem represents a notification style list item.
+type NotificationItem struct {
+ *list.Versioned
+ style NotificationStyle
+ isCurrent bool
+ t *styles.Styles
+ m fuzzy.Match
+ cache map[int]string
+ focused bool
+}
+
+// Finished implements list.Item. Notification items are render-stable
+// outside of explicit SetFocused / SetMatch.
+func (n *NotificationItem) Finished() bool {
+ return true
+}
+
+var (
+ _ Dialog = (*Notifications)(nil)
+ _ ListItem = (*NotificationItem)(nil)
+)
+
+// NewNotifications creates a new notification style picker dialog.
+func NewNotifications(com *common.Common) *Notifications {
+ n := &Notifications{com: com}
+
+ h := help.New()
+ h.Styles = com.Styles.DialogHelpStyles()
+ n.help = h
+
+ n.list = list.NewFilterableList()
+ n.list.Focus()
+
+ n.input = textinput.New()
+ n.input.SetVirtualCursor(false)
+ n.input.Placeholder = "Type to filter"
+ n.input.SetStyles(com.Styles.TextInput)
+ n.input.Focus()
+
+ n.keyMap.Select = key.NewBinding(
+ key.WithKeys("enter", "ctrl+y"),
+ key.WithHelp("enter", "confirm"),
+ )
+ n.keyMap.Next = key.NewBinding(
+ key.WithKeys("down", "ctrl+n"),
+ key.WithHelp("↓", "next item"),
+ )
+ n.keyMap.Previous = key.NewBinding(
+ key.WithKeys("up", "ctrl+p"),
+ key.WithHelp("↑", "previous item"),
+ )
+ n.keyMap.UpDown = key.NewBinding(
+ key.WithKeys("up", "down"),
+ key.WithHelp("↑/↓", "choose"),
+ )
+ n.keyMap.Close = CloseKey
+
+ n.setItems()
+ return n
+}
+
+// ID implements Dialog.
+func (n *Notifications) ID() string {
+ return NotificationsID
+}
+
+// HandleMsg implements [Dialog].
+func (n *Notifications) HandleMsg(msg tea.Msg) Action {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ switch {
+ case key.Matches(msg, n.keyMap.Close):
+ return ActionClose{}
+ case key.Matches(msg, n.keyMap.Previous):
+ n.list.Focus()
+ if n.list.IsSelectedFirst() {
+ n.list.SelectLast()
+ n.list.ScrollToBottom()
+ break
+ }
+ n.list.SelectPrev()
+ n.list.ScrollToSelected()
+ case key.Matches(msg, n.keyMap.Next):
+ n.list.Focus()
+ if n.list.IsSelectedLast() {
+ n.list.SelectFirst()
+ n.list.ScrollToTop()
+ break
+ }
+ n.list.SelectNext()
+ n.list.ScrollToSelected()
+ case key.Matches(msg, n.keyMap.Select):
+ selectedItem := n.list.SelectedItem()
+ if selectedItem == nil {
+ break
+ }
+ notifItem, ok := selectedItem.(*NotificationItem)
+ if !ok {
+ break
+ }
+ return ActionSelectNotificationStyle{Style: notifItem.style.ID}
+ default:
+ var cmd tea.Cmd
+ n.input, cmd = n.input.Update(msg)
+ value := n.input.Value()
+ n.list.SetFilter(value)
+ n.list.ScrollToTop()
+ n.list.SetSelected(0)
+ return ActionCmd{cmd}
+ }
+ }
+ return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (n *Notifications) Cursor() *tea.Cursor {
+ return InputCursor(n.com.Styles, n.input.Cursor())
+}
+
+// Draw implements [Dialog].
+func (n *Notifications) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ t := n.com.Styles
+ width := max(0, min(notificationsDialogMaxWidth, area.Dx()))
+ height := max(0, min(notificationsDialogMaxHeight, area.Dy()))
+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+ t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
+ t.Dialog.HelpView.GetVerticalFrameSize() +
+ t.Dialog.View.GetVerticalFrameSize()
+
+ n.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
+ n.list.SetSize(innerWidth, height-heightOffset)
+ n.help.SetWidth(innerWidth)
+
+ rc := NewRenderContext(t, width)
+ rc.Title = "Notification Style"
+ inputView := t.Dialog.InputPrompt.Render(n.input.View())
+ rc.AddPart(inputView)
+
+ visibleCount := len(n.list.FilteredItems())
+ if n.list.Height() >= visibleCount {
+ n.list.ScrollToTop()
+ } else {
+ n.list.ScrollToSelected()
+ }
+
+ listView := t.Dialog.List.Height(n.list.Height()).Render(n.list.Render())
+ rc.AddPart(listView)
+ rc.Help = n.help.View(n)
+
+ view := rc.Render()
+
+ cur := n.Cursor()
+ DrawCenterCursor(scr, area, view, cur)
+ return cur
+}
+
+// ShortHelp implements [help.KeyMap].
+func (n *Notifications) ShortHelp() []key.Binding {
+ return []key.Binding{
+ n.keyMap.UpDown,
+ n.keyMap.Select,
+ n.keyMap.Close,
+ }
+}
+
+// FullHelp implements [help.KeyMap].
+func (n *Notifications) FullHelp() [][]key.Binding {
+ m := [][]key.Binding{}
+ slice := []key.Binding{
+ n.keyMap.Select,
+ n.keyMap.Next,
+ n.keyMap.Previous,
+ n.keyMap.Close,
+ }
+ for i := 0; i < len(slice); i += 4 {
+ end := min(i+4, len(slice))
+ m = append(m, slice[i:end])
+ }
+ return m
+}
+
+func (n *Notifications) setItems() {
+ cfg := n.com.Config()
+ currentStyle := "auto"
+ if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle != "" {
+ currentStyle = cfg.Options.NotificationStyle
+ }
+
+ items := make([]list.FilterableItem, 0, len(AllNotificationStyles))
+ selectedIndex := 0
+ for i, style := range AllNotificationStyles {
+ item := &NotificationItem{
+ Versioned: list.NewVersioned(),
+ style: style,
+ isCurrent: style.ID == currentStyle,
+ t: n.com.Styles,
+ }
+ items = append(items, item)
+ if style.ID == currentStyle {
+ selectedIndex = i
+ }
+ }
+
+ n.list.SetItems(items...)
+ n.list.SetSelected(selectedIndex)
+ n.list.ScrollToSelected()
+}
+
+// Filter returns the filter value for the notification item.
+func (n *NotificationItem) Filter() string {
+ return n.style.Title
+}
+
+// ID returns the unique identifier for the notification style.
+func (n *NotificationItem) ID() string {
+ return n.style.ID
+}
+
+// SetFocused sets the focus state of the notification item.
+func (n *NotificationItem) SetFocused(focused bool) {
+ if n.focused == focused {
+ return
+ }
+ n.cache = nil
+ n.focused = focused
+ if n.Versioned != nil {
+ n.Bump()
+ }
+}
+
+// SetMatch sets the fuzzy match for the notification item.
+func (n *NotificationItem) SetMatch(m fuzzy.Match) {
+ if sameFuzzyMatch(n.m, m) {
+ return
+ }
+ n.cache = nil
+ n.m = m
+ if n.Versioned != nil {
+ n.Bump()
+ }
+}
+
+// Render returns the string representation of the notification item.
+func (n *NotificationItem) Render(width int) string {
+ info := ""
+ if n.isCurrent {
+ info = "current"
+ }
+ st := ListItemStyles{
+ ItemBlurred: n.t.Dialog.NormalItem,
+ ItemFocused: n.t.Dialog.SelectedItem,
+ InfoTextBlurred: n.t.Dialog.ListItem.InfoBlurred,
+ InfoTextFocused: n.t.Dialog.ListItem.InfoFocused,
+ }
+ return renderItem(st, n.style.Title, info, n.focused, width, n.cache, &n.m)
+}
@@ -13,6 +13,7 @@ import (
"os"
"path/filepath"
"regexp"
+ "runtime"
"slices"
"strconv"
"strings"
@@ -40,6 +41,7 @@ import (
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/skills"
+ "github.com/charmbracelet/crush/internal/stringext"
"github.com/charmbracelet/crush/internal/ui/anim"
"github.com/charmbracelet/crush/internal/ui/attachments"
"github.com/charmbracelet/crush/internal/ui/chat"
@@ -431,21 +433,76 @@ func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
return nil
}
- backend := m.notifyBackend
- return func() tea.Msg {
- if err := backend.Send(n); err != nil {
- slog.Error("Failed to send notification", "error", err)
+ return m.notifyBackend.Send(n)
+}
+
+// selectNotificationBackend chooses the appropriate notification backend based
+// on terminal capabilities, environment, and user configuration. This is a pure
+// function that should be called once during initialization or when capabilities
+// change.
+func selectNotificationBackend(caps common.Capabilities, cfg *config.Config) notification.Backend {
+ // Check for explicit user preference first.
+ if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle != "" {
+ switch cfg.Options.NotificationStyle {
+ case "native":
+ slog.Debug("Using native backend (user preference)")
+ return notification.NewNativeBackend(notification.Icon)
+ case "osc":
+ slog.Debug("Using OSC backend (user preference)", "osc99_supported", caps.OSC99Notifications)
+ return notification.NewOSCBackend(notification.Icon, caps.OSC99Notifications)
+ case "bell":
+ slog.Debug("Using bell backend (user preference)")
+ return notification.NewBellBackend()
+ case "disabled":
+ slog.Debug("Notifications disabled (user preference)")
+ return notification.NoopBackend{}
+ case "auto":
+ // Fall through to auto-detection below.
+ default:
+ slog.Warn("Unknown notification style, using auto", "style", cfg.Options.NotificationStyle)
}
- return nil
}
+
+ // Auto-detect based on environment and capabilities.
+ _, isSSH := caps.Env.LookupEnv("SSH_TTY")
+
+ // SSH sessions use terminal-based notifications (OSC 99 or 777).
+ if isSSH {
+ slog.Debug("Selected OSCBackend for SSH session", "osc99_supported", caps.OSC99Notifications)
+ return notification.NewOSCBackend(notification.Icon, caps.OSC99Notifications)
+ }
+
+ // Local sessions: prefer OSC on macOS because the native backend (beeep)
+ // uses terminal-notifier or AppleScript, which is slow and doesn't display
+ // icons properly. OSC 99 provides a more polished experience with icon support.
+ if runtime.GOOS == "darwin" {
+ slog.Debug("Selected OSCBackend for local macOS session", "osc99_supported", caps.OSC99Notifications)
+ return notification.NewOSCBackend(notification.Icon, caps.OSC99Notifications)
+ }
+
+ // Non-macOS local sessions use native OS notifications if focus events are supported.
+ // Without focus events, we can't suppress notifications when focused, so
+ // we disable them entirely to avoid spamming the user.
+ if caps.ReportFocusEvents {
+ slog.Debug("Selected NativeBackend for local session")
+ return notification.NewNativeBackend(notification.Icon)
+ }
+
+ slog.Debug("Selected NoopBackend (focus events not supported)")
+ return notification.NoopBackend{}
+}
+
+func (m *UI) updateNotificationBackend() {
+ cfg := m.com.Config()
+ m.notifyBackend = selectNotificationBackend(m.caps, cfg)
}
// shouldSendNotification returns true if notifications should be sent based on
-// current state. Focus reporting must be supported, window must not focused,
-// and notifications must not be disabled in config.
+// current state. Focus reporting must be supported, window must not be
+// focused, and notifications must not be disabled in config.
func (m *UI) shouldSendNotification() bool {
cfg := m.com.Config()
- if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
+ if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle == "disabled" {
return false
}
return m.caps.ReportFocusEvents && !m.notifyWindowFocused
@@ -511,9 +568,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
case tea.ModeReportMsg:
- if m.caps.ReportFocusEvents {
- m.notifyBackend = notification.NewNativeBackend(notification.Icon)
- }
+ m.updateNotificationBackend()
+ case uv.UnknownOscEvent:
+ m.updateNotificationBackend()
case tea.FocusMsg:
m.notifyWindowFocused = true
case tea.BlurMsg:
@@ -1358,22 +1415,19 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
m.com.Workspace.PermissionSetSkipRequests(yolo)
m.setEditorPrompt(yolo)
m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.ActionToggleNotifications:
+ case dialog.ActionSelectNotificationStyle:
cfg := m.com.Config()
if cfg != nil && cfg.Options != nil {
- disabled := !cfg.Options.DisableNotifications
- cfg.Options.DisableNotifications = disabled
- if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
+ cfg.Options.NotificationStyle = msg.Style
+ if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.notification_style", msg.Style); err != nil {
cmds = append(cmds, util.ReportError(err))
} else {
- status := "enabled"
- if disabled {
- status = "disabled"
- }
- cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications "+status)))
+ cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications set to: "+msg.Style)))
}
+ // Reinitialize notification backend with new style.
+ m.notifyBackend = selectNotificationBackend(m.caps, cfg)
}
- m.dialog.CloseDialog(dialog.CommandsID)
+ m.dialog.CloseDialog(dialog.NotificationsID)
case dialog.ActionNewSession:
if m.isAgentBusy() {
cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
@@ -1701,7 +1755,14 @@ func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
return util.ReportError(err)
}
- modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
+ var (
+ modelType = stringext.Capitalize(string(msg.ModelType))
+ modelName = msg.Model.Model
+ )
+ if catwalkModel := cfg.GetModel(msg.Model.Provider, msg.Model.Model); catwalkModel != nil && catwalkModel.Name != "" {
+ modelName = catwalkModel.Name
+ }
+ modelMsg := fmt.Sprintf("%s model changed to %s", modelType, modelName)
return util.NewInfoMsg(modelMsg)
})
@@ -3300,6 +3361,10 @@ func (m *UI) openDialog(id string) tea.Cmd {
if cmd := m.openReasoningDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
+ case dialog.NotificationsID:
+ if cmd := m.openNotificationsDialog(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
case dialog.FilePickerID:
if cmd := m.openFilesDialog(); cmd != nil {
cmds = append(cmds, cmd)
@@ -3389,6 +3454,18 @@ func (m *UI) openReasoningDialog() tea.Cmd {
return nil
}
+// openNotificationsDialog opens the notification style picker dialog.
+func (m *UI) openNotificationsDialog() tea.Cmd {
+ if m.dialog.ContainsDialog(dialog.NotificationsID) {
+ m.dialog.BringToFront(dialog.NotificationsID)
+ return nil
+ }
+
+ notificationsDialog := dialog.NewNotifications(m.com)
+ m.dialog.OpenDialog(notificationsDialog)
+ return nil
+}
+
// openSessionsDialog opens the sessions dialog. If the dialog is already open,
// it brings it to the front. Otherwise, it will list all the sessions and open
// the dialog.
@@ -0,0 +1,27 @@
+package notification
+
+import (
+ "log/slog"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+// BellBackend sends notifications by triggering the terminal bell. This is the
+// most basic notification mechanism and works in virtually all terminals, but
+// provides no visual message — just an audible or visual alert depending on
+// terminal configuration.
+type BellBackend struct{}
+
+// NewBellBackend creates a new bell notification backend.
+func NewBellBackend() *BellBackend {
+ return &BellBackend{}
+}
+
+// Send returns a [tea.Cmd] that triggers the terminal bell character (\x07).
+// The terminal will emit an audible beep or visual flash based on user
+// configuration. No message text is displayed.
+func (b *BellBackend) Send(n Notification) tea.Cmd {
+ slog.Debug("Sending bell notification", "title", n.Title, "message", n.Message)
+
+ return tea.Raw("\x07")
+}
@@ -2,6 +2,10 @@
package notification
-// Icon is currently empty on darwin because platform icon support is broken. Do
-// use the icon for OSC notifications, just not native.
-var Icon any = ""
+import _ "embed"
+
+// Icon is the PNG data for the Crush icon, used for OSC 99 notifications.
+// Native macOS notifications don't support custom icons via beeep, but OSC 99 does.
+//
+//go:embed crush-icon.png
+var Icon []byte
@@ -7,7 +7,4 @@ import (
)
//go:embed crush-icon-solo.png
-var icon []byte
-
-// Icon contains the embedded PNG icon data for desktop notifications.
-var Icon any = icon
+var Icon []byte
@@ -3,20 +3,21 @@ package notification
import (
"log/slog"
+ tea "charm.land/bubbletea/v2"
"github.com/gen2brain/beeep"
)
// NativeBackend sends desktop notifications using the native OS notification
// system via beeep.
type NativeBackend struct {
- // icon is the notification icon data (platform-specific).
- icon any
+ // icon is the notification icon data (PNG bytes).
+ icon []byte
// notifyFunc is the function used to send notifications (swappable for testing).
notifyFunc func(title, message string, icon any) error
}
// NewNativeBackend creates a new native notification backend.
-func NewNativeBackend(icon any) *NativeBackend {
+func NewNativeBackend(icon []byte) *NativeBackend {
beeep.AppName = "Crush"
return &NativeBackend{
icon: icon,
@@ -24,18 +25,20 @@ func NewNativeBackend(icon any) *NativeBackend {
}
}
-// Send sends a desktop notification using the native OS notification system.
-func (b *NativeBackend) Send(n Notification) error {
- slog.Debug("Sending native notification", "title", n.Title, "message", n.Message)
+// Send returns a command that sends a desktop notification using the native
+// OS notification system.
+func (b *NativeBackend) Send(n Notification) tea.Cmd {
+ return func() tea.Msg {
+ slog.Debug("Sending native notification", "title", n.Title, "message", n.Message)
- err := b.notifyFunc(n.Title, n.Message, b.icon)
- if err != nil {
- slog.Error("Failed to send notification", "error", err)
- } else {
- slog.Debug("Notification sent successfully")
- }
+ if err := b.notifyFunc(n.Title, n.Message, b.icon); err != nil {
+ slog.Error("Failed to send notification", "error", err)
+ } else {
+ slog.Debug("Notification sent successfully")
+ }
- return err
+ return nil
+ }
}
// SetNotifyFunc allows replacing the notification function for testing.
@@ -1,10 +1,12 @@
package notification
+import tea "charm.land/bubbletea/v2"
+
// NoopBackend is a no-op notification backend that does nothing.
// This is the default backend used when notifications are not supported.
type NoopBackend struct{}
// Send does nothing and returns nil.
-func (NoopBackend) Send(_ Notification) error {
+func (NoopBackend) Send(_ Notification) tea.Cmd {
return nil
}
@@ -1,6 +1,24 @@
// Package notification provides desktop notification support for the UI.
+//
+// This package supports multiple notification backends:
+// - NativeBackend: Uses the native OS notification system (macOS, Windows, Linux)
+// - OSCBackend: Uses OSC escape sequences with automatic protocol detection.
+// Prefers OSC 99 (modern standard with rich notifications) if supported,
+// falling back to OSC 777 (urxvt extension, widely supported). Used for SSH sessions.
+// - BellBackend: Triggers the terminal bell character (\x07), causing an audible
+// beep or visual flash. Works in virtually all terminals but provides no message text.
+// - NoopBackend: A no-op backend that silently discards notifications. Used when
+// notifications are disabled or no suitable backend is available.
+//
+// Backend selection is based on terminal capabilities, environment, and user config:
+// - Users can explicitly set notification_style in crush.json (auto/native/osc/bell/disabled)
+// - Auto mode: SSH sessions use OSC backend (auto-detects OSC 99 vs 777)
+// - Auto mode: Local sessions use native OS notifications
+// - If focus events are not supported in local sessions, notifications are disabled (NoopBackend)
package notification
+import tea "charm.land/bubbletea/v2"
+
// Notification represents a desktop notification request.
type Notification struct {
Title string
@@ -8,8 +26,10 @@ type Notification struct {
}
// Backend defines the interface for sending desktop notifications.
-// Implementations are pure transport - policy decisions (config, focus state)
-// are handled by the caller.
+// Implementations return a tea.Cmd that performs the notification, allowing
+// each backend to choose between synchronous (native OS) and asynchronous
+// (terminal escape sequences) delivery. Policy decisions (config checks,
+// focus state) are handled by the caller.
type Backend interface {
- Send(n Notification) error
+ Send(n Notification) tea.Cmd
}
@@ -1,8 +1,11 @@
package notification_test
import (
+ "encoding/base64"
+ "fmt"
"testing"
+ tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/crush/internal/ui/notification"
"github.com/stretchr/testify/require"
)
@@ -11,11 +14,11 @@ func TestNoopBackend_Send(t *testing.T) {
t.Parallel()
backend := notification.NoopBackend{}
- err := backend.Send(notification.Notification{
+ cmd := backend.Send(notification.Notification{
Title: "Test Title",
Message: "Test Message",
})
- require.NoError(t, err)
+ require.Nil(t, cmd)
}
func TestNativeBackend_Send(t *testing.T) {
@@ -32,12 +35,179 @@ func TestNativeBackend_Send(t *testing.T) {
return nil
})
- err := backend.Send(notification.Notification{
+ cmd := backend.Send(notification.Notification{
Title: "Hello",
Message: "World",
})
- require.NoError(t, err)
+ require.NotNil(t, cmd)
+ msg := cmd()
+ require.Nil(t, msg)
require.Equal(t, "Hello", capturedTitle)
require.Equal(t, "World", capturedMessage)
require.Nil(t, capturedIcon)
}
+
+func extractRawString(t *testing.T, cmd tea.Cmd) string {
+ t.Helper()
+ require.NotNil(t, cmd)
+
+ msg := cmd()
+ raw, ok := msg.(tea.RawMsg)
+ require.True(t, ok)
+
+ s, ok := raw.Msg.(string)
+ require.True(t, ok)
+ return s
+}
+
+func TestOSCBackend_Send_OSC99(t *testing.T) {
+ t.Parallel()
+
+ backend := notification.NewOSCBackend(nil, true)
+ s := extractRawString(t, backend.Send(notification.Notification{
+ Title: "Crush is waiting...",
+ Message: "Agent's turn completed",
+ }))
+
+ require.Contains(t, s, "p=title")
+ require.Contains(t, s, "p=body")
+ require.Contains(t, s, "Crush is waiting...")
+ require.Contains(t, s, "Agent's turn completed")
+ require.NotContains(t, s, "p=icon")
+ require.NotContains(t, s, "\x1b]777;")
+ require.NotContains(t, s, "\x1b]9;")
+}
+
+func TestOSCBackend_Send_OSC99_TitleOnly(t *testing.T) {
+ t.Parallel()
+
+ backend := notification.NewOSCBackend(nil, true)
+ s := extractRawString(t, backend.Send(notification.Notification{
+ Title: "Crush is waiting...",
+ }))
+
+ require.Contains(t, s, "p=title")
+ require.NotContains(t, s, "p=body")
+ require.NotContains(t, s, "\x1b]777;")
+ require.NotContains(t, s, "\x1b]9;")
+}
+
+func TestOSCBackend_Send_OSC99_WithIcon(t *testing.T) {
+ t.Parallel()
+
+ iconData := []byte("fake-png-data")
+ backend := notification.NewOSCBackend(iconData, true)
+ s := extractRawString(t, backend.Send(notification.Notification{
+ Title: "Test",
+ Message: "With icon",
+ }))
+
+ require.Contains(t, s, "p=icon")
+ require.Contains(t, s, "e=1")
+
+ encoded := base64.StdEncoding.EncodeToString(iconData)
+ require.Contains(t, s, fmt.Sprintf(";%s\x07", encoded))
+ require.NotContains(t, s, "\x1b]777;")
+ require.NotContains(t, s, "\x1b]9;")
+}
+
+func TestOSCBackend_Send_OSC777(t *testing.T) {
+ t.Parallel()
+
+ backend := notification.NewOSCBackend(nil, false)
+ s := extractRawString(t, backend.Send(notification.Notification{
+ Title: "Test",
+ Message: "With body",
+ }))
+
+ require.Equal(t, "\x1b]777;notify;Test;With body\x07", s)
+ require.NotContains(t, s, "\x1b]99;")
+ require.NotContains(t, s, "\x1b]9;")
+}
+
+func TestDetectOSC99Support_ValidResponse(t *testing.T) {
+ t.Parallel()
+
+ // Simulate a valid OSC 99 response with title support.
+ seq := "\x1b]99;i=crush-osc99-query:p=?;p=title\x07"
+ require.True(t, notification.DetectOSC99Support(seq))
+}
+
+func TestDetectOSC99Support_MultipleCapabilities(t *testing.T) {
+ t.Parallel()
+
+ // Response indicating support for title, body, and icon.
+ seq := "\x1b]99;i=crush-osc99-query:p=?;p=title,body,icon\x07"
+ require.True(t, notification.DetectOSC99Support(seq))
+}
+
+func TestDetectOSC99Support_InvalidCommand(t *testing.T) {
+ t.Parallel()
+
+ // OSC 98 instead of 99.
+ seq := "\x1b]98;i=crush-osc99-query:p=?;p=title\x07"
+ require.False(t, notification.DetectOSC99Support(seq))
+}
+
+func TestDetectOSC99Support_WrongQueryID(t *testing.T) {
+ t.Parallel()
+
+ // Correct OSC 99 but wrong query ID.
+ seq := "\x1b]99;i=some-other-id:p=?;p=title\x07"
+ require.False(t, notification.DetectOSC99Support(seq))
+}
+
+func TestDetectOSC99Support_NoQueryFlag(t *testing.T) {
+ t.Parallel()
+
+ // Missing p=? query flag.
+ seq := "\x1b]99;i=crush-osc99-query;p=title\x07"
+ require.False(t, notification.DetectOSC99Support(seq))
+}
+
+func TestDetectOSC99Support_NoTitleCapability(t *testing.T) {
+ t.Parallel()
+
+ // Response without title capability (only body).
+ seq := "\x1b]99;i=crush-osc99-query:p=?;p=body\x07"
+ require.False(t, notification.DetectOSC99Support(seq))
+}
+
+func TestDetectOSC99Support_EmptySequence(t *testing.T) {
+ t.Parallel()
+
+ require.False(t, notification.DetectOSC99Support(""))
+}
+
+func TestDetectOSC99Support_MalformedSequence(t *testing.T) {
+ t.Parallel()
+
+ // Missing semicolon separator.
+ seq := "\x1b]99;i=crush-osc99-query:p=?p=title\x07"
+ require.False(t, notification.DetectOSC99Support(seq))
+}
+
+func TestOSC99QuerySequence(t *testing.T) {
+ t.Parallel()
+
+ seq := notification.OSC99QuerySequence()
+ require.Contains(t, seq, "\x1b]99;")
+ require.Contains(t, seq, "i=crush-osc99-query")
+ require.Contains(t, seq, "p=?")
+ require.Contains(t, seq, "\x07")
+}
+
+func TestBellBackend_Send(t *testing.T) {
+ t.Parallel()
+
+ backend := notification.NewBellBackend()
+ s := extractRawString(t, backend.Send(notification.Notification{
+ Title: "Test",
+ Message: "Ignored by bell",
+ }))
+
+ // Bell backend only sends the bell character.
+ require.Equal(t, "\x07", s)
+ require.NotContains(t, s, "Test")
+ require.NotContains(t, s, "Ignored")
+}
@@ -0,0 +1,135 @@
+package notification
+
+import (
+ "encoding/base64"
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/charmbracelet/x/ansi"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+const osc99QueryID = "crush-osc99-query"
+
+// DetectOSC99Support parses an OSC response sequence and returns true if it
+// indicates OSC 99 notification support. This function should be called from
+// the capabilities detection layer to determine terminal support.
+func DetectOSC99Support(seq string) bool {
+ var ok bool
+
+ p := ansi.NewParser()
+ p.SetHandler(ansi.Handler{
+ HandleOsc: func(cmd int, data []byte) {
+ if cmd != 99 {
+ return
+ }
+
+ response := strings.TrimPrefix(string(data), "99;")
+ metadata, payload, found := strings.Cut(response, ";")
+ if !found {
+ return
+ }
+
+ var hasID, hasQuery bool
+ for field := range strings.SplitSeq(metadata, ":") {
+ hasID = hasID || field == "i="+osc99QueryID
+ hasQuery = hasQuery || field == "p=?"
+ }
+ if !hasID || !hasQuery {
+ return
+ }
+
+ ok = isOSC99CapacityPayload(payload)
+ },
+ })
+
+ for i := 0; i < len(seq); i++ {
+ p.Advance(seq[i])
+ }
+
+ return ok
+}
+
+func isOSC99CapacityPayload(payload string) bool {
+ for field := range strings.SplitSeq(payload, ":") {
+ key, value, found := strings.Cut(field, "=")
+ if !found || key != "p" {
+ continue
+ }
+
+ for item := range strings.SplitSeq(value, ",") {
+ if item == "title" {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+// OSC99QuerySequence returns the OSC 99 query sequence used to detect
+// terminal support. This should be sent during capability detection.
+func OSC99QuerySequence() string {
+ return ansi.DesktopNotification("", "i="+osc99QueryID, "p=?")
+}
+
+// OSCBackend sends desktop notifications using OSC escape sequences. It
+// automatically selects the best available protocol: OSC 99 (modern standard)
+// if supported, falling back to OSC 777 (urxvt extension) otherwise.
+type OSCBackend struct {
+ icon []byte
+ supports99 bool
+ notifySeq uint64
+}
+
+// NewOSCBackend creates a new OSC notification backend with automatic protocol
+// detection. If supports99 is true, it uses OSC 99; otherwise it falls back to
+// OSC 777.
+func NewOSCBackend(icon []byte, supports99 bool) *OSCBackend {
+ return &OSCBackend{
+ icon: icon,
+ supports99: supports99,
+ }
+}
+
+// Send returns a [tea.Cmd] that writes OSC escape sequences to the terminal.
+// Uses OSC 99 if supported, otherwise OSC 777.
+func (b *OSCBackend) Send(n Notification) tea.Cmd {
+ if b.supports99 {
+ return b.sendOSC99(n)
+ }
+ return b.sendOSC777(n)
+}
+
+func (b *OSCBackend) sendOSC99(n Notification) tea.Cmd {
+ slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
+
+ var sb strings.Builder
+ b.notifySeq++
+ id := fmt.Sprintf("crush-%d", b.notifySeq)
+
+ appName := "Crush"
+ notificationType := "crush-notification"
+
+ sb.WriteString(ansi.DesktopNotification(n.Title, "i="+id, "d=0", "p=title", "a="+appName, "t="+notificationType))
+ if n.Message != "" {
+ sb.WriteString(ansi.DesktopNotification(n.Message, "i="+id, "d=0", "p=body", "a="+appName, "t="+notificationType))
+ }
+
+ if len(b.icon) > 0 {
+ encoded := base64.StdEncoding.EncodeToString(b.icon)
+ sb.WriteString(ansi.DesktopNotification(encoded, "i="+id, "d=0", "p=icon", "e=1", "a="+appName, "t="+notificationType))
+ }
+
+ sb.WriteString(ansi.DesktopNotification("", "i="+id, "d=1", "a="+appName, "t="+notificationType))
+
+ return tea.Raw(sb.String())
+}
+
+func (b *OSCBackend) sendOSC777(n Notification) tea.Cmd {
+ slog.Debug("Sending OSC 777 notification", "title", n.Title, "message", n.Message)
+
+ return tea.Raw(ansi.URxvtExt("notify", n.Title, n.Message))
+}
@@ -492,9 +492,21 @@
},
"disable_notifications": {
"type": "boolean",
- "description": "Disable desktop notifications",
+ "description": "Deprecated: Use notification_style instead. Disable desktop notifications",
"default": false
},
+ "notification_style": {
+ "type": "string",
+ "enum": [
+ "auto",
+ "native",
+ "osc",
+ "bell",
+ "disabled"
+ ],
+ "description": "Notification style to use. Options: auto (default)",
+ "default": "auto"
+ },
"disabled_skills": {
"items": {
"type": "string",