diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index ff40f751d372d17bf458a33efc7776f258739cf3..88707a8ad752745c21d82f98ba931228d98ded26 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -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 }} diff --git a/go.mod b/go.mod index 65618d4483edf6daa94a99c776b7a4e4a48107a6..43cad83822135c3ee96efcd2a17a37f2f6a85a85 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index fc5bb32dbe1b588b93d3a202051c23b006c1aab9..0ed873217c2e6b7c0f15a3ba64f2e9f156400d80 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 39332b60710cb2cb433dca2132acffbb89fa3441..a12763d0dca9354ec24d1468502eea46a62b7ac2 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -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": diff --git a/internal/agent/hyper/provider.json b/internal/agent/hyper/provider.json index 9611b216d6851f886703a490f1f0b3d246de4a34..9e128f415bad9c8486e266bad536620ac35d4fd9 100644 --- a/internal/agent/hyper/provider.json +++ b/internal/agent/hyper/provider.json @@ -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, diff --git a/internal/config/config.go b/internal/config/config.go index c3895602740c1a9d4b0874bf925bf04b810c317b..fc3bab330231e22606109263d923073f70a00f41 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` } diff --git a/internal/config/load.go b/internal/config/load.go index 08fa3cf2c7b24204146d9790c96e3936761933df..9f43dbebdd145c6bb2ae97c52d57ed0dad9b6940 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -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 != "" { diff --git a/internal/config/store.go b/internal/config/store.go index 3e55509b7132e38805830818fca1e5265b7e03f9..81b19a5926dcd80feb3ee3f24974596aa527aba4 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -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 { diff --git a/internal/ui/common/capabilities.go b/internal/ui/common/capabilities.go index 36d72f3e27f5d837107712849c3d4d7882c94cac..2c45dad9f4af88796edd283a8c9b85f0634cd1c7 100644 --- a/internal/ui/common/capabilities.go +++ b/internal/ui/common/capabilities.go @@ -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 { diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 09a3e5b5a0eb267727e67cdf06199e26ef63337c..0e96b06ad8005e54956dd6db49efee0043b2454d 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -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 { diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index b4c2606bfac3089adda8dd39a7161a7caa0e6ab7..6e17db70d04e30cd263d4d0ea0fd896e995e2a06 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -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, diff --git a/internal/ui/dialog/notifications.go b/internal/ui/dialog/notifications.go new file mode 100644 index 0000000000000000000000000000000000000000..67d2db19ace08ca4ac85d223f7dbb3d2cbcff7c5 --- /dev/null +++ b/internal/ui/dialog/notifications.go @@ -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) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index b58ce5bff83b26c55fce52c34b600b596cc6b632..890dfc7de8a97eae13c4ecbd56ca07b566061408 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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. diff --git a/internal/ui/notification/bell.go b/internal/ui/notification/bell.go new file mode 100644 index 0000000000000000000000000000000000000000..fdea308cb2444905cbe9d1ed2f5a9f860c1bf877 --- /dev/null +++ b/internal/ui/notification/bell.go @@ -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") +} diff --git a/internal/ui/notification/icon_darwin.go b/internal/ui/notification/icon_darwin.go index 27df25009be6bb849afc7b39b631fbbe3c61b6b3..497b235ba99739f0ce5ad6214a10278a24ffa30e 100644 --- a/internal/ui/notification/icon_darwin.go +++ b/internal/ui/notification/icon_darwin.go @@ -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 diff --git a/internal/ui/notification/icon_other.go b/internal/ui/notification/icon_other.go index 27240ad93fc653c9e742a879e76914481e5f1d55..708b4009cb6aeaf42b69828828cdcebfdcef1c46 100644 --- a/internal/ui/notification/icon_other.go +++ b/internal/ui/notification/icon_other.go @@ -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 diff --git a/internal/ui/notification/native.go b/internal/ui/notification/native.go index 4fffa6d2de6798f8c343c3789689844a911b6eb0..ad9afadd27ba6279fb0da539fd059749cbe2b3b7 100644 --- a/internal/ui/notification/native.go +++ b/internal/ui/notification/native.go @@ -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. diff --git a/internal/ui/notification/noop.go b/internal/ui/notification/noop.go index 7e943e38af15ad4e2dcd47c95158bb4abcb6bb56..a30cc5bf40ceee2ff0a1f6bdc79db3013d51130b 100644 --- a/internal/ui/notification/noop.go +++ b/internal/ui/notification/noop.go @@ -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 } diff --git a/internal/ui/notification/notification.go b/internal/ui/notification/notification.go index f6be12bfe8b84c2cf18b4c5f1ae3720e820e6cd5..f7e2ea7227c7d54bb7d591aaf2217569660642b9 100644 --- a/internal/ui/notification/notification.go +++ b/internal/ui/notification/notification.go @@ -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 } diff --git a/internal/ui/notification/notification_test.go b/internal/ui/notification/notification_test.go index 715be608c75328e3bc2b9e820c301a62a17f08a5..2826cb663ef7c6176506b7286f375d7523ff6ce2 100644 --- a/internal/ui/notification/notification_test.go +++ b/internal/ui/notification/notification_test.go @@ -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") +} diff --git a/internal/ui/notification/osc.go b/internal/ui/notification/osc.go new file mode 100644 index 0000000000000000000000000000000000000000..d4b2778ffeefd92276b53223a6c80c9cc32d783c --- /dev/null +++ b/internal/ui/notification/osc.go @@ -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)) +} diff --git a/schema.json b/schema.json index 6a0576faf8260081f376a885e771c49d7119caac..8b90109b39dfc509399a135caa4214b49534e033 100644 --- a/schema.json +++ b/schema.json @@ -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",