Merge remote-tracking branch 'origin/main' into multi-client

Christian Rocha created

Change summary

.github/workflows/labeler.yml                 |   2 
go.mod                                        |  20 
go.sum                                        |  48 +-
internal/agent/coordinator.go                 |  13 
internal/agent/hyper/provider.json            |  66 ++--
internal/config/config.go                     |   3 
internal/config/load.go                       |  69 ++++
internal/config/store.go                      |   3 
internal/ui/common/capabilities.go            |  11 
internal/ui/dialog/actions.go                 |  19 
internal/ui/dialog/commands.go                |  11 
internal/ui/dialog/notifications.go           | 310 +++++++++++++++++++++
internal/ui/model/ui.go                       | 121 ++++++-
internal/ui/notification/bell.go              |  27 +
internal/ui/notification/icon_darwin.go       |  10 
internal/ui/notification/icon_other.go        |   5 
internal/ui/notification/native.go            |  29 +
internal/ui/notification/noop.go              |   4 
internal/ui/notification/notification.go      |  26 +
internal/ui/notification/notification_test.go | 178 +++++++++++
internal/ui/notification/osc.go               | 135 +++++++++
schema.json                                   |  14 
22 files changed, 985 insertions(+), 139 deletions(-)

Detailed changes

.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 }}

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

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=

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":

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,

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"`
 }
 

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 != "" {

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 {

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 {

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 {

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,

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)
+}

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.

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")
+}

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

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

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.

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
 }

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
 }

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")
+}

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))
+}

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",