on main help sign in
Merge remote-tracking branch 'origin/main' into multi-client
commitcc786489e1be11ed339e4edcf41c73d6479bae61 authorChristian Rocha <christian@rocha.is> dateTue 26 May 2026, 15:06 (5 months ago) parent2bb52563f9bb5e
22 files changed+985−139
.github/workflows/labeler.yml+1−1 go.mod+10−10 go.sum+24−24 internal/agent/coordinator.go+11−2 internal/agent/hyper/provider.json+33−33 internal/config/config.go+2−1 internal/config/load.go+69 internal/config/store.go+3 internal/ui/common/capabilities.go+10−1 internal/ui/dialog/actions.go+11−8 internal/ui/dialog/commands.go+3−8 internal/ui/dialog/notifications.go+310 internal/ui/model/ui.go+99−22 internal/ui/notification/bell.go+27 internal/ui/notification/icon_darwin.go+7−3 internal/ui/notification/icon_other.go+1−4 internal/ui/notification/native.go+16−13 internal/ui/notification/noop.go+3−1 internal/ui/notification/notification.go+23−3 internal/ui/notification/notification_test.go+174−4 internal/ui/notification/osc.go+135 schema.json+13−1
.github/workflows/labeler.yml+1−1
@@ -14,6 +14,7 @@ on:
1414
1515permissions:
1616 issues: write
17+ pull-requests: write
1718 contents: read
1819
1920jobs:
@@ -26,5 +27,4 @@ jobs:
2627 enable-versioned-regex: 0
2728 include-title: 1
2829 include-body: 0
29− repo-token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
3030 issue-number: ${{ github.event.inputs.issue-number || github.event.issue.number || github.event.pull_request.number }}
go.mod+10−10
@@ -5,9 +5,9 @@ go 1.26.3
55require (
66 charm.land/bubbles/v2 v2.1.0
77 charm.land/bubbletea/v2 v2.0.6
8− charm.land/catwalk v0.41.8
8+ charm.land/catwalk v0.43.0
99 charm.land/fang/v2 v2.0.1
10− charm.land/fantasy v0.25.2
10+ charm.land/fantasy v0.26.0
1111 charm.land/glamour/v2 v2.0.0
1212 charm.land/lipgloss/v2 v2.0.3
1313 charm.land/log/v2 v2.0.0
@@ -90,8 +90,8 @@ require (
9090 github.com/andybalholm/cascadia v1.3.3 // indirect
9191 github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
9292 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
93− github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect
94− github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
93+ github.com/aws/aws-sdk-go-v2/config v1.32.18 // indirect
94+ github.com/aws/aws-sdk-go-v2/credentials v1.19.17 // indirect
9595 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
9696 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
9797 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
@@ -100,7 +100,7 @@ require (
100100 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
101101 github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
102102 github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
103− github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
103+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect
104104 github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
105105 github.com/aws/smithy-go v1.25.1 // indirect
106106 github.com/aymerick/douceur v0.2.0 // indirect
@@ -137,7 +137,7 @@ require (
137137 github.com/google/go-cmp v0.7.0 // indirect
138138 github.com/google/jsonschema-go v0.4.3 // indirect
139139 github.com/google/s2a-go v0.1.9 // indirect
140− github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
140+ github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
141141 github.com/googleapis/gax-go/v2 v2.22.0 // indirect
142142 github.com/gorilla/css v1.0.1 // indirect
143143 github.com/gorilla/websocket v1.5.3 // indirect
@@ -197,7 +197,7 @@ require (
197197 go.opentelemetry.io/otel/trace v1.43.0 // indirect
198198 go.uber.org/multierr v1.11.0 // indirect
199199 go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
200− golang.org/x/crypto v0.51.0 // indirect
200+ golang.org/x/crypto v0.52.0 // indirect
201201 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
202202 golang.org/x/image v0.38.0 // indirect
203203 golang.org/x/mod v0.35.0 // indirect
@@ -205,9 +205,9 @@ require (
205205 golang.org/x/term v0.43.0 // indirect
206206 golang.org/x/time v0.15.0 // indirect
207207 golang.org/x/tools v0.44.0 // indirect
208− google.golang.org/api v0.279.0 // indirect
209− google.golang.org/genai v1.57.0 // indirect
210− google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
208+ google.golang.org/api v0.280.0 // indirect
209+ google.golang.org/genai v1.58.0 // indirect
210+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect
211211 google.golang.org/grpc v1.81.1 // indirect
212212 google.golang.org/protobuf v1.36.11 // indirect
213213 gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
go.sum+24−24
@@ -2,12 +2,12 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
22charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
33charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
44charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
5−charm.land/catwalk v0.41.8 h1:SxM6KyFD5jtBF2lZZKk6cYCbw1GVlNfn05ZSRtynxEE=
6−charm.land/catwalk v0.41.8/go.mod h1:dtK2+UfdsFJgIriRPodMsSJw0XefrFOq6fdvuS57v3s=
5+charm.land/catwalk v0.43.0 h1:BB5UTfnidXWDj9McAuEIW3tHuH52Yye6rPCs5yAxrcc=
6+charm.land/catwalk v0.43.0/go.mod h1:dtK2+UfdsFJgIriRPodMsSJw0XefrFOq6fdvuS57v3s=
77charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
88charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
9−charm.land/fantasy v0.25.2 h1:K7ZOM3UEay//NHfiFAeIMRaOqhspxe0UyccIJOYrjuo=
10−charm.land/fantasy v0.25.2/go.mod h1:9ykD5gjn8BCjpZqA66vet7H1KsmR+kP0Q0qw1FiqCk0=
9+charm.land/fantasy v0.26.0 h1:u0DtmlrjgnImuNVxKitussEEurMPF1g96Ij7kplNp7w=
10+charm.land/fantasy v0.26.0/go.mod h1:UBF6zhmGRCgUpc+nRj8Thw0YD2DOcsVFf/ifEu9j//U=
1111charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
1212charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
1313charm.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
6060github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
6161github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
6262github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
63−github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
64−github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
65−github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
66−github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
63+github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q=
64+github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY=
65+github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8=
66+github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc=
6767github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
6868github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
6969github.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
8080github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
8181github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
8282github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
83−github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
84−github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
83+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ=
84+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
8585github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
8686github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
8787github.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=
233233github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
234234github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
235235github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
236−github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
237−github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
236+github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
237+github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
238238github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
239239github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
240240github.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
463463golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
464464golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
465465golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
466−golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
467−golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
466+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
467+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
468468golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
469469golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
470470golang.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
555555golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
556556gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
557557gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
558−google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74=
559−google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
560−google.golang.org/genai v1.57.0 h1:qTyG2ynz5dQy2jF4CvZdLHHVslhR0heMue+zM1a4GNM=
561−google.golang.org/genai v1.57.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
562−google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554=
563−google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM=
564−google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc=
565−google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00=
566−google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
567−google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
558+google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk=
559+google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU=
560+google.golang.org/genai v1.58.0 h1:MNA3ZkRyr7MnRwZ9RNZ60p4+UMKV3yYRw6pyHq4pp0U=
561+google.golang.org/genai v1.58.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
562+google.golang.org/genproto v0.0.0-20260523011958-0a33c5d7ca68 h1:cTHF8xtqtBN5sQ4dcoNwOS6FFejvFTkWQbZXsTU3trM=
563+google.golang.org/genproto v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:RRHjglSYABVCWpQ7USCpdfhcd9t4PkajvVwyynZizTc=
564+google.golang.org/genproto/googleapis/api v0.0.0-20260523011958-0a33c5d7ca68 h1:WVVw1Nl19li0fMX++FJ3ye1z9+S1N35QODDy5qpnaXw=
565+google.golang.org/genproto/googleapis/api v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY=
566+google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78=
567+google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
568568google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
569569google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
570570google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
internal/agent/coordinator.go+11−2
@@ -806,7 +806,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str
806806 return azure.New(opts...)
807807}
808808
809−func (c *coordinator) buildBedrockProvider(apiKey string, headers map[string]string) (fantasy.Provider, error) {
809+func (c *coordinator) buildBedrockProvider(apiKey string, headers map[string]string, providerID string) (fantasy.Provider, error) {
810810 var opts []bedrock.Option
811811 if c.cfg.Config().Options.Debug {
812812 httpClient := log.NewHTTPClient()
@@ -815,6 +815,7 @@ func (c *coordinator) buildBedrockProvider(apiKey string, headers map[string]str
815815 if len(headers) > 0 {
816816 opts = append(opts, bedrock.WithHeaders(headers))
817817 }
818+
818819 switch {
819820 case apiKey != "":
820821 opts = append(opts, bedrock.WithAPIKey(apiKey))
@@ -823,6 +824,14 @@ func (c *coordinator) buildBedrockProvider(apiKey string, headers map[string]str
823824 default:
824825 // Skip, let the SDK do authentication.
825826 }
827+
828+ switch providerID {
829+ case string(catwalk.InferenceProviderBedrockEurope):
830+ opts = append(opts, bedrock.WithRegion("eu-west-1"))
831+ default:
832+ opts = append(opts, bedrock.WithRegion("us-east-1"))
833+ }
834+
826835 return bedrock.New(opts...)
827836}
828837
@@ -897,7 +906,7 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
897906 case azure.Name:
898907 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
899908 case bedrock.Name:
900− return c.buildBedrockProvider(apiKey, headers)
909+ return c.buildBedrockProvider(apiKey, headers, providerCfg.ID)
901910 case google.Name:
902911 return c.buildGoogleProvider(baseURL, apiKey, headers)
903912 case "google-vertex":
internal/agent/hyper/provider.json+33−33
@@ -9,9 +9,9 @@
99 {
1010 "id": "deepseek-v4-flash",
1111 "name": "DeepSeek V4 Flash",
12− "cost_per_1m_in": 0.146,
13− "cost_per_1m_out": 0.294,
14− "cost_per_1m_in_cached": 0.073,
12+ "cost_per_1m_in": 0.14,
13+ "cost_per_1m_out": 0.28,
14+ "cost_per_1m_in_cached": 0.07,
1515 "cost_per_1m_out_cached": 0,
1616 "context_window": 1048576,
1717 "default_max_tokens": 104857,
@@ -21,9 +21,9 @@
2121 {
2222 "id": "deepseek-v4-pro",
2323 "name": "DeepSeek V4 Pro",
24− "cost_per_1m_in": 1.788,
25− "cost_per_1m_out": 3.62,
26− "cost_per_1m_in_cached": 0.894,
24+ "cost_per_1m_in": 1.7562,
25+ "cost_per_1m_out": 3.5524,
26+ "cost_per_1m_in_cached": 0.8781,
2727 "cost_per_1m_out_cached": 0,
2828 "context_window": 1048576,
2929 "default_max_tokens": 60000,
@@ -33,9 +33,9 @@
3333 {
3434 "id": "gemma-4-26b-a4b-it",
3535 "name": "Gemma 4 26B A4B",
36− "cost_per_1m_in": 0.1225,
37− "cost_per_1m_out": 0.428,
38− "cost_per_1m_in_cached": 0.06125,
36+ "cost_per_1m_in": 0.102,
37+ "cost_per_1m_out": 0.394,
38+ "cost_per_1m_in_cached": 0.051,
3939 "cost_per_1m_out_cached": 0,
4040 "context_window": 256000,
4141 "default_max_tokens": 25600,
@@ -57,21 +57,21 @@
5757 {
5858 "id": "glm-5.1",
5959 "name": "GLM-5.1",
60− "cost_per_1m_in": 1.33,
61− "cost_per_1m_out": 4.22,
62− "cost_per_1m_in_cached": 0.665,
60+ "cost_per_1m_in": 1.31,
61+ "cost_per_1m_out": 4.2,
62+ "cost_per_1m_in_cached": 0.655,
6363 "cost_per_1m_out_cached": 0,
6464 "context_window": 202750,
65− "default_max_tokens": 1638,
65+ "default_max_tokens": 3276,
6666 "can_reason": true,
6767 "supports_attachments": false
6868 },
6969 {
7070 "id": "gpt-oss-120b",
7171 "name": "gpt-oss-120b",
72− "cost_per_1m_in": 0.162,
73− "cost_per_1m_out": 0.69,
74− "cost_per_1m_in_cached": 0.081,
72+ "cost_per_1m_in": 0.166,
73+ "cost_per_1m_out": 0.632,
74+ "cost_per_1m_in_cached": 0.083,
7575 "cost_per_1m_out_cached": 0,
7676 "context_window": 131072,
7777 "default_max_tokens": 13107,
@@ -87,9 +87,9 @@
8787 {
8888 "id": "kimi-k2.5",
8989 "name": "Kimi K2.5",
90− "cost_per_1m_in": 0.562,
91− "cost_per_1m_out": 2.91,
92− "cost_per_1m_in_cached": 0.281,
90+ "cost_per_1m_in": 0.552,
91+ "cost_per_1m_out": 2.84,
92+ "cost_per_1m_in_cached": 0.276,
9393 "cost_per_1m_out_cached": 0,
9494 "context_window": 262144,
9595 "default_max_tokens": 26214,
@@ -99,9 +99,9 @@
9999 {
100100 "id": "kimi-k2.6",
101101 "name": "Kimi K2.6",
102− "cost_per_1m_in": 1,
103− "cost_per_1m_out": 4.1,
104− "cost_per_1m_in_cached": 0.5,
102+ "cost_per_1m_in": 0.978,
103+ "cost_per_1m_out": 4.12,
104+ "cost_per_1m_in_cached": 0.489,
105105 "cost_per_1m_out_cached": 0,
106106 "context_window": 262142,
107107 "default_max_tokens": 26214,
@@ -111,9 +111,9 @@
111111 {
112112 "id": "llama-3.3-70b-instruct",
113113 "name": "Llama 3.3 70B Instruct",
114− "cost_per_1m_in": 0.638,
115− "cost_per_1m_out": 0.768,
116− "cost_per_1m_in_cached": 0.319,
114+ "cost_per_1m_in": 0.614,
115+ "cost_per_1m_out": 0.864,
116+ "cost_per_1m_in_cached": 0.307,
117117 "cost_per_1m_out_cached": 0,
118118 "context_window": 128000,
119119 "default_max_tokens": 12800,
@@ -135,9 +135,9 @@
135135 {
136136 "id": "minimax-m2.7",
137137 "name": "MiniMax M2.7",
138− "cost_per_1m_in": 0.4158,
138+ "cost_per_1m_in": 0.42,
139139 "cost_per_1m_out": 1.68,
140− "cost_per_1m_in_cached": 0.2079,
140+ "cost_per_1m_in_cached": 0.21,
141141 "cost_per_1m_out_cached": 0,
142142 "context_window": 204800,
143143 "default_max_tokens": 20480,
@@ -195,9 +195,9 @@
195195 {
196196 "id": "qwen3-coder-480b-a35b-instruct-int4-mixed-ar",
197197 "name": "Qwen3 Coder 480B A35B Instruct INT4 Mixed AR",
198− "cost_per_1m_in": 0.746,
199− "cost_per_1m_out": 2.13,
200− "cost_per_1m_in_cached": 0.373,
198+ "cost_per_1m_in": 0.769,
199+ "cost_per_1m_out": 2.235,
200+ "cost_per_1m_in_cached": 0.3845,
201201 "cost_per_1m_out_cached": 0,
202202 "context_window": 106000,
203203 "default_max_tokens": 10600,
@@ -207,9 +207,9 @@
207207 {
208208 "id": "qwen3-next-80b-a3b-instruct",
209209 "name": "Qwen3 Next 80B A3B Instruct",
210− "cost_per_1m_in": 0.128,
211− "cost_per_1m_out": 1.28,
212− "cost_per_1m_in_cached": 0.064,
210+ "cost_per_1m_in": 0.1175,
211+ "cost_per_1m_out": 1.136,
212+ "cost_per_1m_in_cached": 0.05875,
213213 "cost_per_1m_out_cached": 0,
214214 "context_window": 262144,
215215 "default_max_tokens": 26214,
internal/config/config.go+2−1
@@ -278,7 +278,8 @@ type Options struct {
278278 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"`
279279 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
280280 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
281− DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
281+ DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Deprecated: Use notification_style instead. Disable desktop notifications,default=false"`
282+ 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"`
282283 DisabledSkills []string `json:"disabled_skills,omitempty" jsonschema:"description=List of skill names to disable and hide from the agent,example=crush-config"`
283284}
284285
internal/config/load.go+69
@@ -26,6 +26,8 @@ import (
2626 "github.com/charmbracelet/crush/internal/home"
2727 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
2828 "github.com/qjebbs/go-jsons"
29+ "github.com/tidwall/gjson"
30+ "github.com/tidwall/sjson"
2931)
3032
3133const defaultCatwalkURL = "https://catwalk.charm.land"
@@ -33,6 +35,9 @@ const defaultCatwalkURL = "https://catwalk.charm.land"
3335// Load loads the configuration from the default paths and returns a
3436// ConfigStore that owns both the pure-data Config and all runtime state.
3537func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
38+ // Migrate deprecated disable_notifications before loading config.
39+ migrateDisableNotifications()
40+
3641 configPaths := lookupConfigs(workingDir)
3742
3843 cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
@@ -479,6 +484,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
479484 c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
480485 }
481486 }
487+
482488 c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
483489}
484490
@@ -811,6 +817,69 @@ func hasAWSCredentials(env env.Env) bool {
811817 return false
812818}
813819
820+// migrateDisableNotifications migrates the deprecated disable_notifications
821+// field to notification_style. It checks both the user config (~/.config) and
822+// data config (~/.local) files. If disable_notifications is true, it sets
823+// notification_style to "disabled" in the data file. Regardless of value, it
824+// removes disable_notifications from any file that contains it.
825+func migrateDisableNotifications() {
826+ globalConfig := GlobalConfig()
827+ dataConfig := GlobalConfigData()
828+
829+ var wasDisabled bool
830+ filesToClean := []string{}
831+
832+ for _, path := range []string{globalConfig, dataConfig} {
833+ data, err := os.ReadFile(path)
834+ if err != nil {
835+ continue
836+ }
837+ if gjson.Get(string(data), "options.disable_notifications").Exists() {
838+ filesToClean = append(filesToClean, path)
839+ if gjson.Get(string(data), "options.disable_notifications").Bool() {
840+ wasDisabled = true
841+ }
842+ }
843+ }
844+
845+ if len(filesToClean) == 0 {
846+ return
847+ }
848+
849+ // If notifications were disabled, persist the equivalent notification_style.
850+ if wasDisabled {
851+ data, err := os.ReadFile(dataConfig)
852+ if err == nil {
853+ if !gjson.Get(string(data), "options.notification_style").Exists() {
854+ updated, err := sjson.Set(string(data), "options.notification_style", "disabled")
855+ if err == nil {
856+ if err := atomicWriteFile(dataConfig, []byte(updated), 0o600); err != nil {
857+ slog.Warn("Failed to migrate disable_notifications to notification_style", "error", err)
858+ } else {
859+ slog.Info("Migrated disable_notifications: true to notification_style: disabled")
860+ }
861+ }
862+ }
863+ }
864+ }
865+
866+ // Remove disable_notifications from all files that contain it.
867+ for _, path := range filesToClean {
868+ data, err := os.ReadFile(path)
869+ if err != nil {
870+ continue
871+ }
872+ updated, err := sjson.Delete(string(data), "options.disable_notifications")
873+ if err != nil {
874+ slog.Warn("Failed to remove deprecated disable_notifications field", "path", path, "error", err)
875+ continue
876+ }
877+ if err := atomicWriteFile(path, []byte(updated), 0o600); err != nil {
878+ slog.Warn("Failed to write migrated config", "path", path, "error", err)
879+ }
880+ }
881+}
882+
814883// GlobalConfig returns the global configuration file path for the application.
815884func GlobalConfig() string {
816885 if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
internal/config/store.go+3
@@ -649,6 +649,9 @@ func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
649649 s.reloadInProgress = false
650650 }()
651651
652+ // Migrate deprecated disable_notifications before reloading config.
653+ migrateDisableNotifications()
654+
652655 configPaths := lookupConfigs(s.workingDir)
653656 cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
654657 if err != nil {
internal/ui/common/capabilities.go+10−1
@@ -9,6 +9,8 @@ import (
99 uv "github.com/charmbracelet/ultraviolet"
1010 "github.com/charmbracelet/x/ansi"
1111 xstrings "github.com/charmbracelet/x/exp/strings"
12+
13+ "github.com/charmbracelet/crush/internal/ui/notification"
1214)
1315
1416// Capabilities define different terminal capabilities supported.
@@ -35,6 +37,8 @@ type Capabilities struct {
3537 TerminalVersion string
3638 // ReportFocusEvents indicates whether the terminal supports focus events.
3739 ReportFocusEvents bool
40+ // OSC99Notifications indicates whether the terminal supports OSC 99 notifications.
41+ OSC99Notifications bool
3842}
3943
4044// Update updates the capabilities based on the given message.
@@ -63,6 +67,10 @@ func (c *Capabilities) Update(msg any) {
6367 case ansi.ModeFocusEvent:
6468 c.ReportFocusEvents = modeSupported(m.Value)
6569 }
70+ case uv.UnknownOscEvent:
71+ if notification.DetectOSC99Support(string(m)) {
72+ c.OSC99Notifications = true
73+ }
6674 }
6775}
6876
@@ -72,12 +80,13 @@ func QueryCmd(env uv.Environ) tea.Cmd {
7280 var sb strings.Builder
7381 sb.WriteString(ansi.RequestPrimaryDeviceAttributes)
7482 sb.WriteString(ansi.QueryModifyOtherKeys)
83+ sb.WriteString(ansi.RequestModeFocusEvent)
84+ sb.WriteString(notification.OSC99QuerySequence())
7585
7686 // Queries that should only be sent to "smart" normal terminals.
7787 shouldQueryFor := shouldQueryCapabilities(env)
7888 if shouldQueryFor {
7989 sb.WriteString(ansi.RequestNameVersion)
80− sb.WriteString(ansi.RequestModeFocusEvent)
8190 sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
8291 kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
8392 if _, isTmux := env.LookupEnv("TMUX"); isTmux {
internal/ui/dialog/actions.go+11−8
@@ -45,14 +45,17 @@ type ActionSelectModel struct {
4545
4646// Messages for commands
4747type (
48− ActionNewSession struct{}
49− ActionToggleHelp struct{}
50− ActionToggleCompactMode struct{}
51− ActionToggleThinking struct{}
52− ActionTogglePills struct{}
53− ActionExternalEditor struct{}
54− ActionToggleYoloMode struct{}
55− ActionToggleNotifications struct{}
48+ ActionNewSession struct{}
49+ ActionToggleHelp struct{}
50+ ActionToggleCompactMode struct{}
51+ ActionToggleThinking struct{}
52+ ActionTogglePills struct{}
53+ ActionExternalEditor struct{}
54+ ActionToggleYoloMode struct{}
55+ ActionToggleNotifications struct{}
56+ ActionSelectNotificationStyle struct {
57+ Style string
58+ }
5659 ActionToggleTransparentBackground struct{}
5760 ActionInitializeProject struct{}
5861 ActionSummarize struct {
internal/ui/dialog/commands.go+3−8
@@ -511,14 +511,9 @@ func (c *Commands) defaultCommands() []*CommandItem {
511511 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_pills", label, "ctrl+t", ActionTogglePills{}))
512512 }
513513
514− // Add a command for toggling notifications.
515− cfg = c.com.Config()
516− notificationsDisabled := cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications
517− notificationLabel := "Disable Notifications"
518− if notificationsDisabled {
519− notificationLabel = "Enable Notifications"
520− }
521− commands = append(commands, NewCommandItem(c.com.Styles, "toggle_notifications", notificationLabel, "", ActionToggleNotifications{}))
514+ // Add a command for selecting notification style via picker dialog.
515+ notificationLabel := "Notification Style"
516+ commands = append(commands, NewCommandItem(c.com.Styles, "select_notifications", notificationLabel, "", ActionOpenDialog{DialogID: NotificationsID}))
522517
523518 commands = append(
524519 commands,
internal/ui/dialog/notifications.gonew file+310
@@ -0,0 +1,310 @@
1+package dialog
2+
3+import (
4+ "charm.land/bubbles/v2/help"
5+ "charm.land/bubbles/v2/key"
6+ "charm.land/bubbles/v2/textinput"
7+ tea "charm.land/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/ui/common"
9+ "github.com/charmbracelet/crush/internal/ui/list"
10+ "github.com/charmbracelet/crush/internal/ui/styles"
11+ uv "github.com/charmbracelet/ultraviolet"
12+ "github.com/sahilm/fuzzy"
13+)
14+
15+const (
16+ // NotificationsID is the identifier for the notification style picker dialog.
17+ NotificationsID = "notifications"
18+ notificationsDialogMaxWidth = 50
19+ notificationsDialogMaxHeight = 12
20+)
21+
22+// NotificationStyle represents a notification backend option.
23+type NotificationStyle struct {
24+ ID string
25+ Title string
26+ Description string
27+}
28+
29+// AllNotificationStyles lists all available notification styles in order.
30+var AllNotificationStyles = []NotificationStyle{
31+ {ID: "auto", Title: "Auto", Description: "Automatically detect the best backend"},
32+ {ID: "native", Title: "Native", Description: "Use system notifications (macOS/Linux/Windows)"},
33+ {ID: "osc", Title: "OSC", Description: "Use terminal OSC escape sequences"},
34+ {ID: "bell", Title: "Bell", Description: "Use terminal bell character"},
35+ {ID: "disabled", Title: "Disabled", Description: "Turn off notifications"},
36+}
37+
38+// Notifications represents a dialog for selecting notification style.
39+type Notifications struct {
40+ com *common.Common
41+ help help.Model
42+ list *list.FilterableList
43+ input textinput.Model
44+
45+ keyMap struct {
46+ Select key.Binding
47+ Next key.Binding
48+ Previous key.Binding
49+ UpDown key.Binding
50+ Close key.Binding
51+ }
52+}
53+
54+// NotificationItem represents a notification style list item.
55+type NotificationItem struct {
56+ *list.Versioned
57+ style NotificationStyle
58+ isCurrent bool
59+ t *styles.Styles
60+ m fuzzy.Match
61+ cache map[int]string
62+ focused bool
63+}
64+
65+// Finished implements list.Item. Notification items are render-stable
66+// outside of explicit SetFocused / SetMatch.
67+func (n *NotificationItem) Finished() bool {
68+ return true
69+}
70+
71+var (
72+ _ Dialog = (*Notifications)(nil)
73+ _ ListItem = (*NotificationItem)(nil)
74+)
75+
76+// NewNotifications creates a new notification style picker dialog.
77+func NewNotifications(com *common.Common) *Notifications {
78+ n := &Notifications{com: com}
79+
80+ h := help.New()
81+ h.Styles = com.Styles.DialogHelpStyles()
82+ n.help = h
83+
84+ n.list = list.NewFilterableList()
85+ n.list.Focus()
86+
87+ n.input = textinput.New()
88+ n.input.SetVirtualCursor(false)
89+ n.input.Placeholder = "Type to filter"
90+ n.input.SetStyles(com.Styles.TextInput)
91+ n.input.Focus()
92+
93+ n.keyMap.Select = key.NewBinding(
94+ key.WithKeys("enter", "ctrl+y"),
95+ key.WithHelp("enter", "confirm"),
96+ )
97+ n.keyMap.Next = key.NewBinding(
98+ key.WithKeys("down", "ctrl+n"),
99+ key.WithHelp("↓", "next item"),
100+ )
101+ n.keyMap.Previous = key.NewBinding(
102+ key.WithKeys("up", "ctrl+p"),
103+ key.WithHelp("↑", "previous item"),
104+ )
105+ n.keyMap.UpDown = key.NewBinding(
106+ key.WithKeys("up", "down"),
107+ key.WithHelp("↑/↓", "choose"),
108+ )
109+ n.keyMap.Close = CloseKey
110+
111+ n.setItems()
112+ return n
113+}
114+
115+// ID implements Dialog.
116+func (n *Notifications) ID() string {
117+ return NotificationsID
118+}
119+
120+// HandleMsg implements [Dialog].
121+func (n *Notifications) HandleMsg(msg tea.Msg) Action {
122+ switch msg := msg.(type) {
123+ case tea.KeyPressMsg:
124+ switch {
125+ case key.Matches(msg, n.keyMap.Close):
126+ return ActionClose{}
127+ case key.Matches(msg, n.keyMap.Previous):
128+ n.list.Focus()
129+ if n.list.IsSelectedFirst() {
130+ n.list.SelectLast()
131+ n.list.ScrollToBottom()
132+ break
133+ }
134+ n.list.SelectPrev()
135+ n.list.ScrollToSelected()
136+ case key.Matches(msg, n.keyMap.Next):
137+ n.list.Focus()
138+ if n.list.IsSelectedLast() {
139+ n.list.SelectFirst()
140+ n.list.ScrollToTop()
141+ break
142+ }
143+ n.list.SelectNext()
144+ n.list.ScrollToSelected()
145+ case key.Matches(msg, n.keyMap.Select):
146+ selectedItem := n.list.SelectedItem()
147+ if selectedItem == nil {
148+ break
149+ }
150+ notifItem, ok := selectedItem.(*NotificationItem)
151+ if !ok {
152+ break
153+ }
154+ return ActionSelectNotificationStyle{Style: notifItem.style.ID}
155+ default:
156+ var cmd tea.Cmd
157+ n.input, cmd = n.input.Update(msg)
158+ value := n.input.Value()
159+ n.list.SetFilter(value)
160+ n.list.ScrollToTop()
161+ n.list.SetSelected(0)
162+ return ActionCmd{cmd}
163+ }
164+ }
165+ return nil
166+}
167+
168+// Cursor returns the cursor position relative to the dialog.
169+func (n *Notifications) Cursor() *tea.Cursor {
170+ return InputCursor(n.com.Styles, n.input.Cursor())
171+}
172+
173+// Draw implements [Dialog].
174+func (n *Notifications) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
175+ t := n.com.Styles
176+ width := max(0, min(notificationsDialogMaxWidth, area.Dx()))
177+ height := max(0, min(notificationsDialogMaxHeight, area.Dy()))
178+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
179+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
180+ t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
181+ t.Dialog.HelpView.GetVerticalFrameSize() +
182+ t.Dialog.View.GetVerticalFrameSize()
183+
184+ n.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
185+ n.list.SetSize(innerWidth, height-heightOffset)
186+ n.help.SetWidth(innerWidth)
187+
188+ rc := NewRenderContext(t, width)
189+ rc.Title = "Notification Style"
190+ inputView := t.Dialog.InputPrompt.Render(n.input.View())
191+ rc.AddPart(inputView)
192+
193+ visibleCount := len(n.list.FilteredItems())
194+ if n.list.Height() >= visibleCount {
195+ n.list.ScrollToTop()
196+ } else {
197+ n.list.ScrollToSelected()
198+ }
199+
200+ listView := t.Dialog.List.Height(n.list.Height()).Render(n.list.Render())
201+ rc.AddPart(listView)
202+ rc.Help = n.help.View(n)
203+
204+ view := rc.Render()
205+
206+ cur := n.Cursor()
207+ DrawCenterCursor(scr, area, view, cur)
208+ return cur
209+}
210+
211+// ShortHelp implements [help.KeyMap].
212+func (n *Notifications) ShortHelp() []key.Binding {
213+ return []key.Binding{
214+ n.keyMap.UpDown,
215+ n.keyMap.Select,
216+ n.keyMap.Close,
217+ }
218+}
219+
220+// FullHelp implements [help.KeyMap].
221+func (n *Notifications) FullHelp() [][]key.Binding {
222+ m := [][]key.Binding{}
223+ slice := []key.Binding{
224+ n.keyMap.Select,
225+ n.keyMap.Next,
226+ n.keyMap.Previous,
227+ n.keyMap.Close,
228+ }
229+ for i := 0; i < len(slice); i += 4 {
230+ end := min(i+4, len(slice))
231+ m = append(m, slice[i:end])
232+ }
233+ return m
234+}
235+
236+func (n *Notifications) setItems() {
237+ cfg := n.com.Config()
238+ currentStyle := "auto"
239+ if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle != "" {
240+ currentStyle = cfg.Options.NotificationStyle
241+ }
242+
243+ items := make([]list.FilterableItem, 0, len(AllNotificationStyles))
244+ selectedIndex := 0
245+ for i, style := range AllNotificationStyles {
246+ item := &NotificationItem{
247+ Versioned: list.NewVersioned(),
248+ style: style,
249+ isCurrent: style.ID == currentStyle,
250+ t: n.com.Styles,
251+ }
252+ items = append(items, item)
253+ if style.ID == currentStyle {
254+ selectedIndex = i
255+ }
256+ }
257+
258+ n.list.SetItems(items...)
259+ n.list.SetSelected(selectedIndex)
260+ n.list.ScrollToSelected()
261+}
262+
263+// Filter returns the filter value for the notification item.
264+func (n *NotificationItem) Filter() string {
265+ return n.style.Title
266+}
267+
268+// ID returns the unique identifier for the notification style.
269+func (n *NotificationItem) ID() string {
270+ return n.style.ID
271+}
272+
273+// SetFocused sets the focus state of the notification item.
274+func (n *NotificationItem) SetFocused(focused bool) {
275+ if n.focused == focused {
276+ return
277+ }
278+ n.cache = nil
279+ n.focused = focused
280+ if n.Versioned != nil {
281+ n.Bump()
282+ }
283+}
284+
285+// SetMatch sets the fuzzy match for the notification item.
286+func (n *NotificationItem) SetMatch(m fuzzy.Match) {
287+ if sameFuzzyMatch(n.m, m) {
288+ return
289+ }
290+ n.cache = nil
291+ n.m = m
292+ if n.Versioned != nil {
293+ n.Bump()
294+ }
295+}
296+
297+// Render returns the string representation of the notification item.
298+func (n *NotificationItem) Render(width int) string {
299+ info := ""
300+ if n.isCurrent {
301+ info = "current"
302+ }
303+ st := ListItemStyles{
304+ ItemBlurred: n.t.Dialog.NormalItem,
305+ ItemFocused: n.t.Dialog.SelectedItem,
306+ InfoTextBlurred: n.t.Dialog.ListItem.InfoBlurred,
307+ InfoTextFocused: n.t.Dialog.ListItem.InfoFocused,
308+ }
309+ return renderItem(st, n.style.Title, info, n.focused, width, n.cache, &n.m)
310+}
internal/ui/model/ui.go+99−22
@@ -13,6 +13,7 @@ import (
1313 "os"
1414 "path/filepath"
1515 "regexp"
16+ "runtime"
1617 "slices"
1718 "strconv"
1819 "strings"
@@ -40,6 +41,7 @@ import (
4041 "github.com/charmbracelet/crush/internal/pubsub"
4142 "github.com/charmbracelet/crush/internal/session"
4243 "github.com/charmbracelet/crush/internal/skills"
44+ "github.com/charmbracelet/crush/internal/stringext"
4345 "github.com/charmbracelet/crush/internal/ui/anim"
4446 "github.com/charmbracelet/crush/internal/ui/attachments"
4547 "github.com/charmbracelet/crush/internal/ui/chat"
@@ -431,21 +433,76 @@ func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
431433 return nil
432434 }
433435
434− backend := m.notifyBackend
435− return func() tea.Msg {
436− if err := backend.Send(n); err != nil {
437− slog.Error("Failed to send notification", "error", err)
436+ return m.notifyBackend.Send(n)
437+}
438+
439+// selectNotificationBackend chooses the appropriate notification backend based
440+// on terminal capabilities, environment, and user configuration. This is a pure
441+// function that should be called once during initialization or when capabilities
442+// change.
443+func selectNotificationBackend(caps common.Capabilities, cfg *config.Config) notification.Backend {
444+ // Check for explicit user preference first.
445+ if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle != "" {
446+ switch cfg.Options.NotificationStyle {
447+ case "native":
448+ slog.Debug("Using native backend (user preference)")
449+ return notification.NewNativeBackend(notification.Icon)
450+ case "osc":
451+ slog.Debug("Using OSC backend (user preference)", "osc99_supported", caps.OSC99Notifications)
452+ return notification.NewOSCBackend(notification.Icon, caps.OSC99Notifications)
453+ case "bell":
454+ slog.Debug("Using bell backend (user preference)")
455+ return notification.NewBellBackend()
456+ case "disabled":
457+ slog.Debug("Notifications disabled (user preference)")
458+ return notification.NoopBackend{}
459+ case "auto":
460+ // Fall through to auto-detection below.
461+ default:
462+ slog.Warn("Unknown notification style, using auto", "style", cfg.Options.NotificationStyle)
438463 }
439− return nil
440464 }
465+
466+ // Auto-detect based on environment and capabilities.
467+ _, isSSH := caps.Env.LookupEnv("SSH_TTY")
468+
469+ // SSH sessions use terminal-based notifications (OSC 99 or 777).
470+ if isSSH {
471+ slog.Debug("Selected OSCBackend for SSH session", "osc99_supported", caps.OSC99Notifications)
472+ return notification.NewOSCBackend(notification.Icon, caps.OSC99Notifications)
473+ }
474+
475+ // Local sessions: prefer OSC on macOS because the native backend (beeep)
476+ // uses terminal-notifier or AppleScript, which is slow and doesn't display
477+ // icons properly. OSC 99 provides a more polished experience with icon support.
478+ if runtime.GOOS == "darwin" {
479+ slog.Debug("Selected OSCBackend for local macOS session", "osc99_supported", caps.OSC99Notifications)
480+ return notification.NewOSCBackend(notification.Icon, caps.OSC99Notifications)
481+ }
482+
483+ // Non-macOS local sessions use native OS notifications if focus events are supported.
484+ // Without focus events, we can't suppress notifications when focused, so
485+ // we disable them entirely to avoid spamming the user.
486+ if caps.ReportFocusEvents {
487+ slog.Debug("Selected NativeBackend for local session")
488+ return notification.NewNativeBackend(notification.Icon)
489+ }
490+
491+ slog.Debug("Selected NoopBackend (focus events not supported)")
492+ return notification.NoopBackend{}
493+}
494+
495+func (m *UI) updateNotificationBackend() {
496+ cfg := m.com.Config()
497+ m.notifyBackend = selectNotificationBackend(m.caps, cfg)
441498}
442499
443500// shouldSendNotification returns true if notifications should be sent based on
444−// current state. Focus reporting must be supported, window must not focused,
445−// and notifications must not be disabled in config.
501+// current state. Focus reporting must be supported, window must not be
502+// focused, and notifications must not be disabled in config.
446503func (m *UI) shouldSendNotification() bool {
447504 cfg := m.com.Config()
448− if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
505+ if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle == "disabled" {
449506 return false
450507 }
451508 return m.caps.ReportFocusEvents && !m.notifyWindowFocused
@@ -511,9 +568,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
511568 }
512569 cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
513570 case tea.ModeReportMsg:
514− if m.caps.ReportFocusEvents {
515− m.notifyBackend = notification.NewNativeBackend(notification.Icon)
516− }
571+ m.updateNotificationBackend()
572+ case uv.UnknownOscEvent:
573+ m.updateNotificationBackend()
517574 case tea.FocusMsg:
518575 m.notifyWindowFocused = true
519576 case tea.BlurMsg:
@@ -1358,22 +1415,19 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13581415 m.com.Workspace.PermissionSetSkipRequests(yolo)
13591416 m.setEditorPrompt(yolo)
13601417 m.dialog.CloseDialog(dialog.CommandsID)
1361− case dialog.ActionToggleNotifications:
1418+ case dialog.ActionSelectNotificationStyle:
13621419 cfg := m.com.Config()
13631420 if cfg != nil && cfg.Options != nil {
1364− disabled := !cfg.Options.DisableNotifications
1365− cfg.Options.DisableNotifications = disabled
1366− if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
1421+ cfg.Options.NotificationStyle = msg.Style
1422+ if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.notification_style", msg.Style); err != nil {
13671423 cmds = append(cmds, util.ReportError(err))
13681424 } else {
1369− status := "enabled"
1370− if disabled {
1371− status = "disabled"
1372− }
1373− cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications "+status)))
1425+ cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications set to: "+msg.Style)))
13741426 }
1427+ // Reinitialize notification backend with new style.
1428+ m.notifyBackend = selectNotificationBackend(m.caps, cfg)
13751429 }
1376− m.dialog.CloseDialog(dialog.CommandsID)
1430+ m.dialog.CloseDialog(dialog.NotificationsID)
13771431 case dialog.ActionNewSession:
13781432 if m.isAgentBusy() {
13791433 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 {
17011755 return util.ReportError(err)
17021756 }
17031757
1704− modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1758+ var (
1759+ modelType = stringext.Capitalize(string(msg.ModelType))
1760+ modelName = msg.Model.Model
1761+ )
1762+ if catwalkModel := cfg.GetModel(msg.Model.Provider, msg.Model.Model); catwalkModel != nil && catwalkModel.Name != "" {
1763+ modelName = catwalkModel.Name
1764+ }
1765+ modelMsg := fmt.Sprintf("%s model changed to %s", modelType, modelName)
17051766
17061767 return util.NewInfoMsg(modelMsg)
17071768 })
@@ -3300,6 +3361,10 @@ func (m *UI) openDialog(id string) tea.Cmd {
33003361 if cmd := m.openReasoningDialog(); cmd != nil {
33013362 cmds = append(cmds, cmd)
33023363 }
3364+ case dialog.NotificationsID:
3365+ if cmd := m.openNotificationsDialog(); cmd != nil {
3366+ cmds = append(cmds, cmd)
3367+ }
33033368 case dialog.FilePickerID:
33043369 if cmd := m.openFilesDialog(); cmd != nil {
33053370 cmds = append(cmds, cmd)
@@ -3389,6 +3454,18 @@ func (m *UI) openReasoningDialog() tea.Cmd {
33893454 return nil
33903455}
33913456
3457+// openNotificationsDialog opens the notification style picker dialog.
3458+func (m *UI) openNotificationsDialog() tea.Cmd {
3459+ if m.dialog.ContainsDialog(dialog.NotificationsID) {
3460+ m.dialog.BringToFront(dialog.NotificationsID)
3461+ return nil
3462+ }
3463+
3464+ notificationsDialog := dialog.NewNotifications(m.com)
3465+ m.dialog.OpenDialog(notificationsDialog)
3466+ return nil
3467+}
3468+
33923469// openSessionsDialog opens the sessions dialog. If the dialog is already open,
33933470// it brings it to the front. Otherwise, it will list all the sessions and open
33943471// the dialog.
internal/ui/notification/bell.gonew file+27
@@ -0,0 +1,27 @@
1+package notification
2+
3+import (
4+ "log/slog"
5+
6+ tea "charm.land/bubbletea/v2"
7+)
8+
9+// BellBackend sends notifications by triggering the terminal bell. This is the
10+// most basic notification mechanism and works in virtually all terminals, but
11+// provides no visual message — just an audible or visual alert depending on
12+// terminal configuration.
13+type BellBackend struct{}
14+
15+// NewBellBackend creates a new bell notification backend.
16+func NewBellBackend() *BellBackend {
17+ return &BellBackend{}
18+}
19+
20+// Send returns a [tea.Cmd] that triggers the terminal bell character (\x07).
21+// The terminal will emit an audible beep or visual flash based on user
22+// configuration. No message text is displayed.
23+func (b *BellBackend) Send(n Notification) tea.Cmd {
24+ slog.Debug("Sending bell notification", "title", n.Title, "message", n.Message)
25+
26+ return tea.Raw("\x07")
27+}
internal/ui/notification/icon_darwin.go+7−3
@@ -2,6 +2,10 @@
22
33package notification
44
5−// Icon is currently empty on darwin because platform icon support is broken. Do
6−// use the icon for OSC notifications, just not native.
7−var Icon any = ""
5+import _ "embed"
6+
7+// Icon is the PNG data for the Crush icon, used for OSC 99 notifications.
8+// Native macOS notifications don't support custom icons via beeep, but OSC 99 does.
9+//
10+//go:embed crush-icon.png
11+var Icon []byte
internal/ui/notification/icon_other.go+1−4
@@ -7,7 +7,4 @@ import (
77)
88
99//go:embed crush-icon-solo.png
10−var icon []byte
11−
12−// Icon contains the embedded PNG icon data for desktop notifications.
13−var Icon any = icon
10+var Icon []byte
internal/ui/notification/native.go+16−13
@@ -3,20 +3,21 @@ package notification
33import (
44 "log/slog"
55
6+ tea "charm.land/bubbletea/v2"
67 "github.com/gen2brain/beeep"
78)
89
910// NativeBackend sends desktop notifications using the native OS notification
1011// system via beeep.
1112type NativeBackend struct {
12− // icon is the notification icon data (platform-specific).
13− icon any
13+ // icon is the notification icon data (PNG bytes).
14+ icon []byte
1415 // notifyFunc is the function used to send notifications (swappable for testing).
1516 notifyFunc func(title, message string, icon any) error
1617}
1718
1819// NewNativeBackend creates a new native notification backend.
19−func NewNativeBackend(icon any) *NativeBackend {
20+func NewNativeBackend(icon []byte) *NativeBackend {
2021 beeep.AppName = "Crush"
2122 return &NativeBackend{
2223 icon: icon,
@@ -24,18 +25,20 @@ func NewNativeBackend(icon any) *NativeBackend {
2425 }
2526}
2627
27−// Send sends a desktop notification using the native OS notification system.
28−func (b *NativeBackend) Send(n Notification) error {
29− slog.Debug("Sending native notification", "title", n.Title, "message", n.Message)
28+// Send returns a command that sends a desktop notification using the native
29+// OS notification system.
30+func (b *NativeBackend) Send(n Notification) tea.Cmd {
31+ return func() tea.Msg {
32+ slog.Debug("Sending native notification", "title", n.Title, "message", n.Message)
3033
31− err := b.notifyFunc(n.Title, n.Message, b.icon)
32− if err != nil {
33− slog.Error("Failed to send notification", "error", err)
34− } else {
35− slog.Debug("Notification sent successfully")
36− }
34+ if err := b.notifyFunc(n.Title, n.Message, b.icon); err != nil {
35+ slog.Error("Failed to send notification", "error", err)
36+ } else {
37+ slog.Debug("Notification sent successfully")
38+ }
3739
38− return err
40+ return nil
41+ }
3942}
4043
4144// SetNotifyFunc allows replacing the notification function for testing.
internal/ui/notification/noop.go+3−1
@@ -1,10 +1,12 @@
11package notification
22
3+import tea "charm.land/bubbletea/v2"
4+
35// NoopBackend is a no-op notification backend that does nothing.
46// This is the default backend used when notifications are not supported.
57type NoopBackend struct{}
68
79// Send does nothing and returns nil.
8−func (NoopBackend) Send(_ Notification) error {
10+func (NoopBackend) Send(_ Notification) tea.Cmd {
911 return nil
1012}
internal/ui/notification/notification.go+23−3
@@ -1,6 +1,24 @@
11// Package notification provides desktop notification support for the UI.
2+//
3+// This package supports multiple notification backends:
4+// - NativeBackend: Uses the native OS notification system (macOS, Windows, Linux)
5+// - OSCBackend: Uses OSC escape sequences with automatic protocol detection.
6+// Prefers OSC 99 (modern standard with rich notifications) if supported,
7+// falling back to OSC 777 (urxvt extension, widely supported). Used for SSH sessions.
8+// - BellBackend: Triggers the terminal bell character (\x07), causing an audible
9+// beep or visual flash. Works in virtually all terminals but provides no message text.
10+// - NoopBackend: A no-op backend that silently discards notifications. Used when
11+// notifications are disabled or no suitable backend is available.
12+//
13+// Backend selection is based on terminal capabilities, environment, and user config:
14+// - Users can explicitly set notification_style in crush.json (auto/native/osc/bell/disabled)
15+// - Auto mode: SSH sessions use OSC backend (auto-detects OSC 99 vs 777)
16+// - Auto mode: Local sessions use native OS notifications
17+// - If focus events are not supported in local sessions, notifications are disabled (NoopBackend)
218package notification
319
20+import tea "charm.land/bubbletea/v2"
21+
422// Notification represents a desktop notification request.
523type Notification struct {
624 Title string
@@ -8,8 +26,10 @@ type Notification struct {
826}
927
1028// Backend defines the interface for sending desktop notifications.
11−// Implementations are pure transport - policy decisions (config, focus state)
12−// are handled by the caller.
29+// Implementations return a tea.Cmd that performs the notification, allowing
30+// each backend to choose between synchronous (native OS) and asynchronous
31+// (terminal escape sequences) delivery. Policy decisions (config checks,
32+// focus state) are handled by the caller.
1333type Backend interface {
14− Send(n Notification) error
34+ Send(n Notification) tea.Cmd
1535}
internal/ui/notification/notification_test.go+174−4
@@ -1,8 +1,11 @@
11package notification_test
22
33import (
4+ "encoding/base64"
5+ "fmt"
46 "testing"
57
8+ tea "charm.land/bubbletea/v2"
69 "github.com/charmbracelet/crush/internal/ui/notification"
710 "github.com/stretchr/testify/require"
811)
@@ -11,11 +14,11 @@ func TestNoopBackend_Send(t *testing.T) {
1114 t.Parallel()
1215
1316 backend := notification.NoopBackend{}
14− err := backend.Send(notification.Notification{
17+ cmd := backend.Send(notification.Notification{
1518 Title: "Test Title",
1619 Message: "Test Message",
1720 })
18− require.NoError(t, err)
21+ require.Nil(t, cmd)
1922}
2023
2124func TestNativeBackend_Send(t *testing.T) {
@@ -32,12 +35,179 @@ func TestNativeBackend_Send(t *testing.T) {
3235 return nil
3336 })
3437
35− err := backend.Send(notification.Notification{
38+ cmd := backend.Send(notification.Notification{
3639 Title: "Hello",
3740 Message: "World",
3841 })
39− require.NoError(t, err)
42+ require.NotNil(t, cmd)
43+ msg := cmd()
44+ require.Nil(t, msg)
4045 require.Equal(t, "Hello", capturedTitle)
4146 require.Equal(t, "World", capturedMessage)
4247 require.Nil(t, capturedIcon)
4348}
49+
50+func extractRawString(t *testing.T, cmd tea.Cmd) string {
51+ t.Helper()
52+ require.NotNil(t, cmd)
53+
54+ msg := cmd()
55+ raw, ok := msg.(tea.RawMsg)
56+ require.True(t, ok)
57+
58+ s, ok := raw.Msg.(string)
59+ require.True(t, ok)
60+ return s
61+}
62+
63+func TestOSCBackend_Send_OSC99(t *testing.T) {
64+ t.Parallel()
65+
66+ backend := notification.NewOSCBackend(nil, true)
67+ s := extractRawString(t, backend.Send(notification.Notification{
68+ Title: "Crush is waiting...",
69+ Message: "Agent's turn completed",
70+ }))
71+
72+ require.Contains(t, s, "p=title")
73+ require.Contains(t, s, "p=body")
74+ require.Contains(t, s, "Crush is waiting...")
75+ require.Contains(t, s, "Agent's turn completed")
76+ require.NotContains(t, s, "p=icon")
77+ require.NotContains(t, s, "\x1b]777;")
78+ require.NotContains(t, s, "\x1b]9;")
79+}
80+
81+func TestOSCBackend_Send_OSC99_TitleOnly(t *testing.T) {
82+ t.Parallel()
83+
84+ backend := notification.NewOSCBackend(nil, true)
85+ s := extractRawString(t, backend.Send(notification.Notification{
86+ Title: "Crush is waiting...",
87+ }))
88+
89+ require.Contains(t, s, "p=title")
90+ require.NotContains(t, s, "p=body")
91+ require.NotContains(t, s, "\x1b]777;")
92+ require.NotContains(t, s, "\x1b]9;")
93+}
94+
95+func TestOSCBackend_Send_OSC99_WithIcon(t *testing.T) {
96+ t.Parallel()
97+
98+ iconData := []byte("fake-png-data")
99+ backend := notification.NewOSCBackend(iconData, true)
100+ s := extractRawString(t, backend.Send(notification.Notification{
101+ Title: "Test",
102+ Message: "With icon",
103+ }))
104+
105+ require.Contains(t, s, "p=icon")
106+ require.Contains(t, s, "e=1")
107+
108+ encoded := base64.StdEncoding.EncodeToString(iconData)
109+ require.Contains(t, s, fmt.Sprintf(";%s\x07", encoded))
110+ require.NotContains(t, s, "\x1b]777;")
111+ require.NotContains(t, s, "\x1b]9;")
112+}
113+
114+func TestOSCBackend_Send_OSC777(t *testing.T) {
115+ t.Parallel()
116+
117+ backend := notification.NewOSCBackend(nil, false)
118+ s := extractRawString(t, backend.Send(notification.Notification{
119+ Title: "Test",
120+ Message: "With body",
121+ }))
122+
123+ require.Equal(t, "\x1b]777;notify;Test;With body\x07", s)
124+ require.NotContains(t, s, "\x1b]99;")
125+ require.NotContains(t, s, "\x1b]9;")
126+}
127+
128+func TestDetectOSC99Support_ValidResponse(t *testing.T) {
129+ t.Parallel()
130+
131+ // Simulate a valid OSC 99 response with title support.
132+ seq := "\x1b]99;i=crush-osc99-query:p=?;p=title\x07"
133+ require.True(t, notification.DetectOSC99Support(seq))
134+}
135+
136+func TestDetectOSC99Support_MultipleCapabilities(t *testing.T) {
137+ t.Parallel()
138+
139+ // Response indicating support for title, body, and icon.
140+ seq := "\x1b]99;i=crush-osc99-query:p=?;p=title,body,icon\x07"
141+ require.True(t, notification.DetectOSC99Support(seq))
142+}
143+
144+func TestDetectOSC99Support_InvalidCommand(t *testing.T) {
145+ t.Parallel()
146+
147+ // OSC 98 instead of 99.
148+ seq := "\x1b]98;i=crush-osc99-query:p=?;p=title\x07"
149+ require.False(t, notification.DetectOSC99Support(seq))
150+}
151+
152+func TestDetectOSC99Support_WrongQueryID(t *testing.T) {
153+ t.Parallel()
154+
155+ // Correct OSC 99 but wrong query ID.
156+ seq := "\x1b]99;i=some-other-id:p=?;p=title\x07"
157+ require.False(t, notification.DetectOSC99Support(seq))
158+}
159+
160+func TestDetectOSC99Support_NoQueryFlag(t *testing.T) {
161+ t.Parallel()
162+
163+ // Missing p=? query flag.
164+ seq := "\x1b]99;i=crush-osc99-query;p=title\x07"
165+ require.False(t, notification.DetectOSC99Support(seq))
166+}
167+
168+func TestDetectOSC99Support_NoTitleCapability(t *testing.T) {
169+ t.Parallel()
170+
171+ // Response without title capability (only body).
172+ seq := "\x1b]99;i=crush-osc99-query:p=?;p=body\x07"
173+ require.False(t, notification.DetectOSC99Support(seq))
174+}
175+
176+func TestDetectOSC99Support_EmptySequence(t *testing.T) {
177+ t.Parallel()
178+
179+ require.False(t, notification.DetectOSC99Support(""))
180+}
181+
182+func TestDetectOSC99Support_MalformedSequence(t *testing.T) {
183+ t.Parallel()
184+
185+ // Missing semicolon separator.
186+ seq := "\x1b]99;i=crush-osc99-query:p=?p=title\x07"
187+ require.False(t, notification.DetectOSC99Support(seq))
188+}
189+
190+func TestOSC99QuerySequence(t *testing.T) {
191+ t.Parallel()
192+
193+ seq := notification.OSC99QuerySequence()
194+ require.Contains(t, seq, "\x1b]99;")
195+ require.Contains(t, seq, "i=crush-osc99-query")
196+ require.Contains(t, seq, "p=?")
197+ require.Contains(t, seq, "\x07")
198+}
199+
200+func TestBellBackend_Send(t *testing.T) {
201+ t.Parallel()
202+
203+ backend := notification.NewBellBackend()
204+ s := extractRawString(t, backend.Send(notification.Notification{
205+ Title: "Test",
206+ Message: "Ignored by bell",
207+ }))
208+
209+ // Bell backend only sends the bell character.
210+ require.Equal(t, "\x07", s)
211+ require.NotContains(t, s, "Test")
212+ require.NotContains(t, s, "Ignored")
213+}
internal/ui/notification/osc.gonew file+135
@@ -0,0 +1,135 @@
1+package notification
2+
3+import (
4+ "encoding/base64"
5+ "fmt"
6+ "log/slog"
7+ "strings"
8+
9+ "github.com/charmbracelet/x/ansi"
10+
11+ tea "charm.land/bubbletea/v2"
12+)
13+
14+const osc99QueryID = "crush-osc99-query"
15+
16+// DetectOSC99Support parses an OSC response sequence and returns true if it
17+// indicates OSC 99 notification support. This function should be called from
18+// the capabilities detection layer to determine terminal support.
19+func DetectOSC99Support(seq string) bool {
20+ var ok bool
21+
22+ p := ansi.NewParser()
23+ p.SetHandler(ansi.Handler{
24+ HandleOsc: func(cmd int, data []byte) {
25+ if cmd != 99 {
26+ return
27+ }
28+
29+ response := strings.TrimPrefix(string(data), "99;")
30+ metadata, payload, found := strings.Cut(response, ";")
31+ if !found {
32+ return
33+ }
34+
35+ var hasID, hasQuery bool
36+ for field := range strings.SplitSeq(metadata, ":") {
37+ hasID = hasID || field == "i="+osc99QueryID
38+ hasQuery = hasQuery || field == "p=?"
39+ }
40+ if !hasID || !hasQuery {
41+ return
42+ }
43+
44+ ok = isOSC99CapacityPayload(payload)
45+ },
46+ })
47+
48+ for i := 0; i < len(seq); i++ {
49+ p.Advance(seq[i])
50+ }
51+
52+ return ok
53+}
54+
55+func isOSC99CapacityPayload(payload string) bool {
56+ for field := range strings.SplitSeq(payload, ":") {
57+ key, value, found := strings.Cut(field, "=")
58+ if !found || key != "p" {
59+ continue
60+ }
61+
62+ for item := range strings.SplitSeq(value, ",") {
63+ if item == "title" {
64+ return true
65+ }
66+ }
67+ }
68+
69+ return false
70+}
71+
72+// OSC99QuerySequence returns the OSC 99 query sequence used to detect
73+// terminal support. This should be sent during capability detection.
74+func OSC99QuerySequence() string {
75+ return ansi.DesktopNotification("", "i="+osc99QueryID, "p=?")
76+}
77+
78+// OSCBackend sends desktop notifications using OSC escape sequences. It
79+// automatically selects the best available protocol: OSC 99 (modern standard)
80+// if supported, falling back to OSC 777 (urxvt extension) otherwise.
81+type OSCBackend struct {
82+ icon []byte
83+ supports99 bool
84+ notifySeq uint64
85+}
86+
87+// NewOSCBackend creates a new OSC notification backend with automatic protocol
88+// detection. If supports99 is true, it uses OSC 99; otherwise it falls back to
89+// OSC 777.
90+func NewOSCBackend(icon []byte, supports99 bool) *OSCBackend {
91+ return &OSCBackend{
92+ icon: icon,
93+ supports99: supports99,
94+ }
95+}
96+
97+// Send returns a [tea.Cmd] that writes OSC escape sequences to the terminal.
98+// Uses OSC 99 if supported, otherwise OSC 777.
99+func (b *OSCBackend) Send(n Notification) tea.Cmd {
100+ if b.supports99 {
101+ return b.sendOSC99(n)
102+ }
103+ return b.sendOSC777(n)
104+}
105+
106+func (b *OSCBackend) sendOSC99(n Notification) tea.Cmd {
107+ slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
108+
109+ var sb strings.Builder
110+ b.notifySeq++
111+ id := fmt.Sprintf("crush-%d", b.notifySeq)
112+
113+ appName := "Crush"
114+ notificationType := "crush-notification"
115+
116+ sb.WriteString(ansi.DesktopNotification(n.Title, "i="+id, "d=0", "p=title", "a="+appName, "t="+notificationType))
117+ if n.Message != "" {
118+ sb.WriteString(ansi.DesktopNotification(n.Message, "i="+id, "d=0", "p=body", "a="+appName, "t="+notificationType))
119+ }
120+
121+ if len(b.icon) > 0 {
122+ encoded := base64.StdEncoding.EncodeToString(b.icon)
123+ sb.WriteString(ansi.DesktopNotification(encoded, "i="+id, "d=0", "p=icon", "e=1", "a="+appName, "t="+notificationType))
124+ }
125+
126+ sb.WriteString(ansi.DesktopNotification("", "i="+id, "d=1", "a="+appName, "t="+notificationType))
127+
128+ return tea.Raw(sb.String())
129+}
130+
131+func (b *OSCBackend) sendOSC777(n Notification) tea.Cmd {
132+ slog.Debug("Sending OSC 777 notification", "title", n.Title, "message", n.Message)
133+
134+ return tea.Raw(ansi.URxvtExt("notify", n.Title, n.Message))
135+}
schema.json+13−1
@@ -492,9 +492,21 @@
492492 },
493493 "disable_notifications": {
494494 "type": "boolean",
495− "description": "Disable desktop notifications",
495+ "description": "Deprecated: Use notification_style instead. Disable desktop notifications",
496496 "default": false
497497 },
498+ "notification_style": {
499+ "type": "string",
500+ "enum": [
501+ "auto",
502+ "native",
503+ "osc",
504+ "bell",
505+ "disabled"
506+ ],
507+ "description": "Notification style to use. Options: auto (default)",
508+ "default": "auto"
509+ },
498510 "disabled_skills": {
499511 "items": {
500512 "type": "string",