on main help sign in
feat(notification): alert on turn completion and permission request (#1356)

* feat(notification): add em' Assisted-by: Claude Sonnet 4.5 via Crush * refactor(permission): check allowlist first * docs(notification): correct example, fix rendering * fix(notification): bump godbus/dbus to v5.2.2 v5.1.0's FreeBSD SendNullByte() was in a CGO file, so it was excluded when building with CGO_ENABLED=0, causing the freebsd/amd64 cross-build to fail. v5.2.2 rewrites it in pure Go.

commitf8da538c509f77dffd1ad1926f8a41ac75953016 authorAmolith <amolith@secluded.site> dateWed 11 Mar 2026, 09:12 (7 months ago) parentae720e3
21 files changed+361−36
README.md+19 go.mod+10 go.sum+27 internal/agent/agent.go+16 internal/agent/common_test.go+9−1 internal/agent/coordinator.go+17−10 internal/agent/notify/notify.go+19 internal/app/app.go+16−6 internal/config/config.go+1 internal/permission/permission.go+6−6 internal/ui/common/capabilities.go+2−2 internal/ui/model/ui.go+77−11 internal/ui/notification/crush-icon-solo.png internal/ui/notification/crush-icon.png internal/ui/notification/icon_darwin.go+7 internal/ui/notification/icon_other.go+13 internal/ui/notification/native.go+49 internal/ui/notification/noop.go+10 internal/ui/notification/notification.go+15 internal/ui/notification/notification_test.go+43 schema.json+5
README.md+19
@@ -424,6 +424,25 @@ git clone https://github.com/anthropics/skills.git _temp
424424mv _temp/skills/* . ; rm -r -force _temp
425425```
426426
427+### Desktop notifications
428+
429+Crush sends desktop notifications when a tool call requires permission and when
430+the agent finishes its turn. They're only sent when the terminal window isn't
431+focused _and_ your terminal supports reporting the focus state.
432+
433+```jsonc
434+{
435+ "$schema": "https://charm.land/crush.json",
436+ "options": {
437+ "disable_notifications": false // default
438+ }
439+}
440+```
441+
442+To disable desktop notifications, set `disable_notifications` to `true` in your
443+configuration. On macOS, notifications currently lack icons due to platform
444+limitations.
445+
427446### Initialization
428447
429448When you initialize a project, Crush analyzes your codebase and creates
go.mod+10
@@ -38,6 +38,7 @@ require (
3838 github.com/denisbrodbeck/machineid v1.0.1
3939 github.com/disintegration/imaging v1.6.2
4040 github.com/dustin/go-humanize v1.0.1
41+ github.com/gen2brain/beeep v0.11.2
4142 github.com/go-git/go-git/v5 v5.17.0
4243 github.com/google/uuid v1.6.0
4344 github.com/invopop/jsonschema v0.13.0
@@ -77,6 +78,7 @@ require (
7778 cloud.google.com/go/auth v0.18.2 // indirect
7879 cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
7980 cloud.google.com/go/compute/metadata v0.9.0 // indirect
81+ git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
8082 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
8183 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
8284 github.com/andybalholm/cascadia v1.3.3 // indirect
@@ -106,6 +108,7 @@ require (
106108 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
107109 github.com/dlclark/regexp2 v1.11.5 // indirect
108110 github.com/ebitengine/purego v0.10.0 // indirect
111+ github.com/esiqveland/notify v0.13.3 // indirect
109112 github.com/felixge/httpsnoop v1.0.4 // indirect
110113 github.com/fsnotify/fsnotify v1.9.0 // indirect
111114 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
@@ -114,9 +117,11 @@ require (
114117 github.com/go-logfmt/logfmt v0.6.0 // indirect
115118 github.com/go-logr/logr v1.4.3 // indirect
116119 github.com/go-logr/stdr v1.2.2 // indirect
120+ github.com/go-ole/go-ole v1.3.0 // indirect
117121 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
118122 github.com/goccy/go-json v0.10.5 // indirect
119123 github.com/goccy/go-yaml v1.19.2 // indirect
124+ github.com/godbus/dbus/v5 v5.2.2 // indirect
120125 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
121126 github.com/google/go-cmp v0.7.0 // indirect
122127 github.com/google/jsonschema-go v0.4.2 // indirect
@@ -127,6 +132,7 @@ require (
127132 github.com/gorilla/websocket v1.5.3 // indirect
128133 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
129134 github.com/inconshreveable/mousetrap v1.1.0 // indirect
135+ github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
130136 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
131137 github.com/kaptinlin/go-i18n v0.2.12 // indirect
132138 github.com/kaptinlin/jsonpointer v0.4.17 // indirect
@@ -147,13 +153,17 @@ require (
147153 github.com/muesli/roff v0.1.0 // indirect
148154 github.com/ncruces/go-strftime v1.0.0 // indirect
149155 github.com/ncruces/julianday v1.0.0 // indirect
156+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
150157 github.com/pierrec/lz4/v4 v4.1.25 // indirect
151158 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
152159 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
153160 github.com/segmentio/asm v1.2.1 // indirect
154161 github.com/segmentio/encoding v0.5.3 // indirect
162+ github.com/sergeymakinen/go-bmp v1.0.0 // indirect
163+ github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
155164 github.com/sethvargo/go-retry v0.3.0 // indirect
156165 github.com/spf13/pflag v1.0.9 // indirect
166+ github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
157167 github.com/tetratelabs/wazero v1.11.0 // indirect
158168 github.com/tidwall/match v1.1.1 // indirect
159169 github.com/tidwall/pretty v1.2.1 // indirect
go.sum+27
@@ -22,6 +22,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
2222cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
2323cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
2424cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
25+git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=
26+git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=
2527github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
2628github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
2729github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
@@ -158,11 +160,15 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNf
158160github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
159161github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
160162github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
163+github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=
164+github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE=
161165github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
162166github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
163167github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
164168github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
165169github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
170+github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA=
171+github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc=
166172github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
167173github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
168174github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
@@ -178,6 +184,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
178184github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
179185github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
180186github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
187+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
188+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
181189github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
182190github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
183191github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
@@ -186,6 +194,9 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
186194github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
187195github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
188196github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
197+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
198+github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
199+github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
189200github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
190201github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
191202github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
@@ -220,6 +231,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
220231github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
221232github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
222233github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
234+github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o=
235+github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ=
223236github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
224237github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
225238github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -282,6 +295,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
282295github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
283296github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
284297github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
298+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
299+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
285300github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
286301github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
287302github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
@@ -321,6 +336,10 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
321336github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
322337github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
323338github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
339+github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=
340+github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
341+github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=
342+github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
324343github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
325344github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
326345github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
@@ -334,10 +353,17 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
334353github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
335354github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
336355github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
356+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
357+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
337358github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
338359github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
360+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
361+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
362+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
339363github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
340364github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
365+github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
366+github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
341367github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
342368github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
343369github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -518,6 +544,7 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN
518544gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
519545gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
520546gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
547+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
521548gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
522549gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
523550modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
internal/agent/agent.go+16
@@ -32,12 +32,14 @@ import (
3232 "charm.land/fantasy/providers/vercel"
3333 "charm.land/lipgloss/v2"
3434 "github.com/charmbracelet/crush/internal/agent/hyper"
35+ "github.com/charmbracelet/crush/internal/agent/notify"
3536 "github.com/charmbracelet/crush/internal/agent/tools"
3637 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
3738 "github.com/charmbracelet/crush/internal/config"
3839 "github.com/charmbracelet/crush/internal/csync"
3940 "github.com/charmbracelet/crush/internal/message"
4041 "github.com/charmbracelet/crush/internal/permission"
42+ "github.com/charmbracelet/crush/internal/pubsub"
4143 "github.com/charmbracelet/crush/internal/session"
4244 "github.com/charmbracelet/crush/internal/stringext"
4345 "github.com/charmbracelet/crush/internal/version"
@@ -73,6 +75,7 @@ type SessionAgentCall struct {
7375 TopK *int64
7476 FrequencyPenalty *float64
7577 PresencePenalty *float64
78+ NonInteractive bool
7679}
7780
7881type SessionAgent interface {
@@ -109,6 +112,7 @@ type sessionAgent struct {
109112 messages message.Service
110113 disableAutoSummarize bool
111114 isYolo bool
115+ notify pubsub.Publisher[notify.Notification]
112116
113117 messageQueue *csync.Map[string, []SessionAgentCall]
114118 activeRequests *csync.Map[string, context.CancelFunc]
@@ -125,6 +129,7 @@ type SessionAgentOptions struct {
125129 Sessions session.Service
126130 Messages message.Service
127131 Tools []fantasy.AgentTool
132+ Notify pubsub.Publisher[notify.Notification]
128133}
129134
130135func NewSessionAgent(
@@ -141,6 +146,7 @@ func NewSessionAgent(
141146 disableAutoSummarize: opts.DisableAutoSummarize,
142147 tools: csync.NewSliceFrom(opts.Tools),
143148 isYolo: opts.IsYolo,
149+ notify: opts.Notify,
144150 messageQueue: csync.NewMap[string, []SessionAgentCall](),
145151 activeRequests: csync.NewMap[string, context.CancelFunc](),
146152 }
@@ -532,6 +538,16 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
532538 return nil, err
533539 }
534540
541+ // Send notification that agent has finished its turn (skip for
542+ // nested/non-interactive sessions).
543+ if !call.NonInteractive && a.notify != nil {
544+ a.notify.Publish(pubsub.CreatedEvent, notify.Notification{
545+ SessionID: call.SessionID,
546+ SessionTitle: currentSession.Title,
547+ Type: notify.TypeAgentFinished,
548+ })
549+ }
550+
535551 if shouldSummarize {
536552 a.activeRequests.Del(call.SessionID)
537553 if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
internal/agent/common_test.go+9−1
@@ -153,7 +153,15 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro
153153 DefaultMaxTokens: 10000,
154154 },
155155 }
156− agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, false, true, env.sessions, env.messages, tools})
156+ agent := NewSessionAgent(SessionAgentOptions{
157+ LargeModel: largeModel,
158+ SmallModel: smallModel,
159+ SystemPrompt: systemPrompt,
160+ IsYolo: true,
161+ Sessions: env.sessions,
162+ Messages: env.messages,
163+ Tools: tools,
164+ })
157165 return agent
158166}
159167
internal/agent/coordinator.go+17−10
@@ -18,6 +18,7 @@ import (
1818 "charm.land/catwalk/pkg/catwalk"
1919 "charm.land/fantasy"
2020 "github.com/charmbracelet/crush/internal/agent/hyper"
21+ "github.com/charmbracelet/crush/internal/agent/notify"
2122 "github.com/charmbracelet/crush/internal/agent/prompt"
2223 "github.com/charmbracelet/crush/internal/agent/tools"
2324 "github.com/charmbracelet/crush/internal/config"
@@ -28,6 +29,7 @@ import (
2829 "github.com/charmbracelet/crush/internal/message"
2930 "github.com/charmbracelet/crush/internal/oauth/copilot"
3031 "github.com/charmbracelet/crush/internal/permission"
32+ "github.com/charmbracelet/crush/internal/pubsub"
3133 "github.com/charmbracelet/crush/internal/session"
3234 "golang.org/x/sync/errgroup"
3335
@@ -79,6 +81,7 @@ type coordinator struct {
7981 history history.Service
8082 filetracker filetracker.Service
8183 lspManager *lsp.Manager
84+ notify pubsub.Publisher[notify.Notification]
8285
8386 currentAgent SessionAgent
8487 agents map[string]SessionAgent
@@ -95,6 +98,7 @@ func NewCoordinator(
9598 history history.Service,
9699 filetracker filetracker.Service,
97100 lspManager *lsp.Manager,
101+ notify pubsub.Publisher[notify.Notification],
98102) (Coordinator, error) {
99103 c := &coordinator{
100104 cfg: cfg,
@@ -104,6 +108,7 @@ func NewCoordinator(
104108 history: history,
105109 filetracker: filetracker,
106110 lspManager: lspManager,
111+ notify: notify,
107112 agents: make(map[string]SessionAgent),
108113 }
109114
@@ -380,16 +385,17 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
380385
381386 largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
382387 result := NewSessionAgent(SessionAgentOptions{
383− large,
384− small,
385− largeProviderCfg.SystemPromptPrefix,
386− "",
387− isSubAgent,
388− c.cfg.Options.DisableAutoSummarize,
389− c.permissions.SkipRequests(),
390− c.sessions,
391− c.messages,
392− nil,
388+ LargeModel: large,
389+ SmallModel: small,
390+ SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix,
391+ SystemPrompt: "",
392+ IsSubAgent: isSubAgent,
393+ DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
394+ IsYolo: c.permissions.SkipRequests(),
395+ Sessions: c.sessions,
396+ Messages: c.messages,
397+ Tools: nil,
398+ Notify: c.notify,
393399 })
394400
395401 c.readyWg.Go(func() error {
@@ -994,6 +1000,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f
9941000 TopK: model.ModelCfg.TopK,
9951001 FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
9961002 PresencePenalty: model.ModelCfg.PresencePenalty,
1003+ NonInteractive: true,
9971004 })
9981005 if err != nil {
9991006 return fantasy.NewTextErrorResponse("error generating response"), nil
internal/agent/notify/notify.gonew file+19
@@ -0,0 +1,19 @@
1+// Package notify defines domain notification types for agent events.
2+// These types are decoupled from UI concerns so the agent can publish
3+// events without importing UI packages.
4+package notify
5+
6+// Type identifies the kind of agent notification.
7+type Type string
8+
9+const (
10+ // TypeAgentFinished indicates the agent has completed its turn.
11+ TypeAgentFinished Type = "agent_finished"
12+)
13+
14+// Notification represents a domain event published by the agent.
15+type Notification struct {
16+ SessionID string
17+ SessionTitle string
18+ Type Type
19+}
internal/app/app.go+16−6
@@ -19,6 +19,7 @@ import (
1919 "charm.land/fantasy"
2020 "charm.land/lipgloss/v2"
2121 "github.com/charmbracelet/crush/internal/agent"
22+ "github.com/charmbracelet/crush/internal/agent/notify"
2223 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
2324 "github.com/charmbracelet/crush/internal/config"
2425 "github.com/charmbracelet/crush/internal/db"
@@ -68,8 +69,9 @@ type App struct {
6869 tuiWG *sync.WaitGroup
6970
7071 // global context and cleanup functions
71− globalCtx context.Context
72− cleanupFuncs []func(context.Context) error
72+ globalCtx context.Context
73+ cleanupFuncs []func(context.Context) error
74+ agentNotifications *pubsub.Broker[notify.Notification]
7375}
7476
7577// New initializes a new application instance.
@@ -96,9 +98,10 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
9698
9799 config: cfg,
98100
99− events: make(chan tea.Msg, 100),
100− serviceEventsWG: &sync.WaitGroup{},
101− tuiWG: &sync.WaitGroup{},
101+ events: make(chan tea.Msg, 100),
102+ serviceEventsWG: &sync.WaitGroup{},
103+ tuiWG: &sync.WaitGroup{},
104+ agentNotifications: pubsub.NewBroker[notify.Notification](),
102105 }
103106
104107 app.setupEvents()
@@ -112,7 +115,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
112115 app.cleanupFuncs = append(
113116 app.cleanupFuncs,
114117 func(context.Context) error { return conn.Close() },
115− mcp.Close,
118+ func(ctx context.Context) error { return mcp.Close(ctx) },
116119 )
117120
118121 // TODO: remove the concept of agent config, most likely.
@@ -143,6 +146,11 @@ func (app *App) Config() *config.Config {
143146 return app.config
144147}
145148
149+// AgentNotifications returns the broker for agent notification events.
150+func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] {
151+ return app.agentNotifications
152+}
153+
146154// RunNonInteractive runs the application in non-interactive mode with the
147155// given prompt, printing to stdout.
148156func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
@@ -414,6 +422,7 @@ func (app *App) setupEvents() {
414422 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
415423 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
416424 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
425+ setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events)
417426 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
418427 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
419428 cleanupFunc := func(context.Context) error {
@@ -486,6 +495,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
486495 app.History,
487496 app.FileTracker,
488497 app.LSPManager,
498+ app.agentNotifications,
489499 )
490500 if err != nil {
491501 slog.Error("Failed to create coder agent", "err", err)
internal/config/config.go+1
@@ -261,6 +261,7 @@ type Options struct {
261261 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"`
262262 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
263263 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
264+ DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
264265}
265266
266267type MCPs map[string]MCPConfig
internal/permission/permission.go+6−6
@@ -134,6 +134,12 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
134134 return true, nil
135135 }
136136
137+ // Check if the tool/action combination is in the allowlist
138+ commandKey := opts.ToolName + ":" + opts.Action
139+ if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
140+ return true, nil
141+ }
142+
137143 // tell the UI that a permission was requested
138144 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
139145 ToolCallID: opts.ToolCallID,
@@ -141,12 +147,6 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
141147 s.requestMu.Lock()
142148 defer s.requestMu.Unlock()
143149
144− // Check if the tool/action combination is in the allowlist
145− commandKey := opts.ToolName + ":" + opts.Action
146− if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
147− return true, nil
148− }
149−
150150 s.autoApproveSessionsMu.RLock()
151151 autoApprove := s.autoApproveSessions[opts.SessionID]
152152 s.autoApproveSessionsMu.RUnlock()
internal/ui/common/capabilities.go+2−2
@@ -58,7 +58,7 @@ func (c *Capabilities) Update(msg any) {
5858 }
5959 case tea.TerminalVersionMsg:
6060 c.TerminalVersion = m.Name
61− case uv.ModeReportEvent:
61+ case tea.ModeReportMsg:
6262 switch m.Mode {
6363 case ansi.ModeFocusEvent:
6464 c.ReportFocusEvents = modeSupported(m.Value)
@@ -77,7 +77,7 @@ func QueryCmd(env uv.Environ) tea.Cmd {
7777 shouldQueryFor := shouldQueryCapabilities(env)
7878 if shouldQueryFor {
7979 sb.WriteString(ansi.RequestNameVersion)
80− // sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications.
80+ sb.WriteString(ansi.RequestModeFocusEvent)
8181 sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
8282 kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
8383 if _, isTmux := env.LookupEnv("TMUX"); isTmux {
internal/ui/model/ui.go+77−11
@@ -25,6 +25,7 @@ import (
2525 tea "charm.land/bubbletea/v2"
2626 "charm.land/catwalk/pkg/catwalk"
2727 "charm.land/lipgloss/v2"
28+ "github.com/charmbracelet/crush/internal/agent/notify"
2829 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
2930 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
3031 "github.com/charmbracelet/crush/internal/app"
@@ -45,6 +46,7 @@ import (
4546 "github.com/charmbracelet/crush/internal/ui/dialog"
4647 fimage "github.com/charmbracelet/crush/internal/ui/image"
4748 "github.com/charmbracelet/crush/internal/ui/logo"
49+ "github.com/charmbracelet/crush/internal/ui/notification"
4850 "github.com/charmbracelet/crush/internal/ui/styles"
4951 "github.com/charmbracelet/crush/internal/ui/util"
5052 "github.com/charmbracelet/crush/internal/version"
@@ -201,6 +203,9 @@ type UI struct {
201203 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
202204 sidebarLogo string
203205
206+ // Notification state
207+ notifyBackend notification.Backend
208+ notifyWindowFocused bool
204209 // custom commands & mcp commands
205210 customCommands []commands.CustomCommand
206211 mcpPrompts []commands.MCPPrompt
@@ -280,17 +285,19 @@ func New(com *common.Common) *UI {
280285 header := newHeader(com)
281286
282287 ui := &UI{
283− com: com,
284− dialog: dialog.NewOverlay(),
285− keyMap: keyMap,
286− textarea: ta,
287− chat: ch,
288− header: header,
289− completions: comp,
290− attachments: attachments,
291− todoSpinner: todoSpinner,
292− lspStates: make(map[string]app.LSPClientInfo),
293− mcpStates: make(map[string]mcp.ClientInfo),
288+ com: com,
289+ dialog: dialog.NewOverlay(),
290+ keyMap: keyMap,
291+ textarea: ta,
292+ chat: ch,
293+ header: header,
294+ completions: comp,
295+ attachments: attachments,
296+ todoSpinner: todoSpinner,
297+ lspStates: make(map[string]app.LSPClientInfo),
298+ mcpStates: make(map[string]mcp.ClientInfo),
299+ notifyBackend: notification.NoopBackend{},
300+ notifyWindowFocused: true,
294301 }
295302
296303 status := NewStatus(com, ui)
@@ -342,6 +349,32 @@ func (m *UI) Init() tea.Cmd {
342349 return tea.Batch(cmds...)
343350}
344351
352+// sendNotification returns a command that sends a notification if allowed by policy.
353+func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
354+ if !m.shouldSendNotification() {
355+ return nil
356+ }
357+
358+ backend := m.notifyBackend
359+ return func() tea.Msg {
360+ if err := backend.Send(n); err != nil {
361+ slog.Error("Failed to send notification", "error", err)
362+ }
363+ return nil
364+ }
365+}
366+
367+// shouldSendNotification returns true if notifications should be sent based on
368+// current state. Focus reporting must be supported, window must not focused,
369+// and notifications must not be disabled in config.
370+func (m *UI) shouldSendNotification() bool {
371+ cfg := m.com.Config()
372+ if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
373+ return false
374+ }
375+ return m.caps.ReportFocusEvents && !m.notifyWindowFocused
376+}
377+
345378// setState changes the UI state and focus.
346379func (m *UI) setState(state uiState, focus uiFocusState) {
347380 if state == uiLanding {
@@ -397,6 +430,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
397430 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
398431 }
399432 cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
433+ case tea.ModeReportMsg:
434+ if m.caps.ReportFocusEvents {
435+ m.notifyBackend = notification.NewNativeBackend(notification.Icon)
436+ }
437+ case tea.FocusMsg:
438+ m.notifyWindowFocused = true
439+ case tea.BlurMsg:
440+ m.notifyWindowFocused = false
441+ case pubsub.Event[notify.Notification]:
442+ if cmd := m.handleAgentNotification(msg.Payload); cmd != nil {
443+ cmds = append(cmds, cmd)
444+ }
400445 case loadSessionMsg:
401446 if m.forceCompactMode {
402447 m.isCompact = true
@@ -542,6 +587,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
542587 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
543588 cmds = append(cmds, cmd)
544589 }
590+ if cmd := m.sendNotification(notification.Notification{
591+ Title: "Crush is waiting...",
592+ Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
593+ }); cmd != nil {
594+ cmds = append(cmds, cmd)
595+ }
545596 case pubsub.Event[permission.PermissionNotification]:
546597 m.handlePermissionNotification(msg.Payload)
547598 case cancelTimerExpiredMsg:
@@ -1964,6 +2015,7 @@ func (m *UI) View() tea.View {
19642015 v.BackgroundColor = m.com.Styles.Background
19652016 }
19662017 v.MouseMode = tea.MouseModeCellMotion
2018+ v.ReportFocus = m.caps.ReportFocusEvents
19672019 v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
19682020
19692021 canvas := uv.NewScreenBuffer(m.width, m.height)
@@ -2980,6 +3032,20 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
29803032 }
29813033}
29823034
3035+// handleAgentNotification translates domain agent events into desktop
3036+// notifications using the UI notification backend.
3037+func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3038+ switch n.Type {
3039+ case notify.TypeAgentFinished:
3040+ return m.sendNotification(notification.Notification{
3041+ Title: "Crush is waiting...",
3042+ Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3043+ })
3044+ default:
3045+ return nil
3046+ }
3047+}
3048+
29833049// newSession clears the current session state and prepares for a new session.
29843050// The actual session creation happens when the user sends their first message.
29853051// Returns a command to reload prompt history.
internal/ui/notification/crush-icon-solo.pngnew file

Binary file not shown.

internal/ui/notification/crush-icon.pngnew file

Binary file not shown.

internal/ui/notification/icon_darwin.gonew file+7
@@ -0,0 +1,7 @@
1+//go:build darwin
2+
3+package notification
4+
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 = ""
internal/ui/notification/icon_other.gonew file+13
@@ -0,0 +1,13 @@
1+//go:build !darwin
2+
3+package notification
4+
5+import (
6+ _ "embed"
7+)
8+
9+//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
internal/ui/notification/native.gonew file+49
@@ -0,0 +1,49 @@
1+package notification
2+
3+import (
4+ "log/slog"
5+
6+ "github.com/gen2brain/beeep"
7+)
8+
9+// NativeBackend sends desktop notifications using the native OS notification
10+// system via beeep.
11+type NativeBackend struct {
12+ // icon is the notification icon data (platform-specific).
13+ icon any
14+ // notifyFunc is the function used to send notifications (swappable for testing).
15+ notifyFunc func(title, message string, icon any) error
16+}
17+
18+// NewNativeBackend creates a new native notification backend.
19+func NewNativeBackend(icon any) *NativeBackend {
20+ beeep.AppName = "Crush"
21+ return &NativeBackend{
22+ icon: icon,
23+ notifyFunc: beeep.Notify,
24+ }
25+}
26+
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)
30+
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+ }
37+
38+ return err
39+}
40+
41+// SetNotifyFunc allows replacing the notification function for testing.
42+func (b *NativeBackend) SetNotifyFunc(fn func(title, message string, icon any) error) {
43+ b.notifyFunc = fn
44+}
45+
46+// ResetNotifyFunc resets the notification function to the default.
47+func (b *NativeBackend) ResetNotifyFunc() {
48+ b.notifyFunc = beeep.Notify
49+}
internal/ui/notification/noop.gonew file+10
@@ -0,0 +1,10 @@
1+package notification
2+
3+// NoopBackend is a no-op notification backend that does nothing.
4+// This is the default backend used when notifications are not supported.
5+type NoopBackend struct{}
6+
7+// Send does nothing and returns nil.
8+func (NoopBackend) Send(_ Notification) error {
9+ return nil
10+}
internal/ui/notification/notification.gonew file+15
@@ -0,0 +1,15 @@
1+// Package notification provides desktop notification support for the UI.
2+package notification
3+
4+// Notification represents a desktop notification request.
5+type Notification struct {
6+ Title string
7+ Message string
8+}
9+
10+// Backend defines the interface for sending desktop notifications.
11+// Implementations are pure transport - policy decisions (config, focus state)
12+// are handled by the caller.
13+type Backend interface {
14+ Send(n Notification) error
15+}
internal/ui/notification/notification_test.gonew file+43
@@ -0,0 +1,43 @@
1+package notification_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/ui/notification"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNoopBackend_Send(t *testing.T) {
11+ t.Parallel()
12+
13+ backend := notification.NoopBackend{}
14+ err := backend.Send(notification.Notification{
15+ Title: "Test Title",
16+ Message: "Test Message",
17+ })
18+ require.NoError(t, err)
19+}
20+
21+func TestNativeBackend_Send(t *testing.T) {
22+ t.Parallel()
23+
24+ backend := notification.NewNativeBackend(nil)
25+
26+ var capturedTitle, capturedMessage string
27+ var capturedIcon any
28+ backend.SetNotifyFunc(func(title, message string, icon any) error {
29+ capturedTitle = title
30+ capturedMessage = message
31+ capturedIcon = icon
32+ return nil
33+ })
34+
35+ err := backend.Send(notification.Notification{
36+ Title: "Hello",
37+ Message: "World",
38+ })
39+ require.NoError(t, err)
40+ require.Equal(t, "Hello", capturedTitle)
41+ require.Equal(t, "World", capturedMessage)
42+ require.Nil(t, capturedIcon)
43+}
schema.json+5
@@ -451,6 +451,11 @@
451451 "type": "boolean",
452452 "description": "Show indeterminate progress updates during long operations",
453453 "default": true
454+ },
455+ "disable_notifications": {
456+ "type": "boolean",
457+ "description": "Disable desktop notifications",
458+ "default": false
454459 }
455460 },
456461 "additionalProperties": false,