Detailed changes
@@ -424,6 +424,25 @@ git clone https://github.com/anthropics/skills.git _temp
mv _temp/skills/* . ; rm -r -force _temp
```
+### Desktop notifications
+
+Crush sends desktop notifications when a tool call requires permission and when
+the agent finishes its turn. They're only sent when the terminal window isn't
+focused _and_ your terminal supports reporting the focus state.
+
+```jsonc
+{
+ "$schema": "https://charm.land/crush.json",
+ "options": {
+ "disable_notifications": false // default
+ }
+}
+```
+
+To disable desktop notifications, set `disable_notifications` to `true` in your
+configuration. On macOS, notifications currently lack icons due to platform
+limitations.
+
### Initialization
When you initialize a project, Crush analyzes your codebase and creates
@@ -38,6 +38,7 @@ require (
github.com/denisbrodbeck/machineid v1.0.1
github.com/disintegration/imaging v1.6.2
github.com/dustin/go-humanize v1.0.1
+ github.com/gen2brain/beeep v0.11.2
github.com/go-git/go-git/v5 v5.17.0
github.com/google/uuid v1.6.0
github.com/invopop/jsonschema v0.13.0
@@ -77,6 +78,7 @@ require (
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
+ git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
@@ -106,6 +108,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
+ github.com/esiqveland/notify v0.13.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
@@ -114,9 +117,11 @@ require (
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
@@ -127,6 +132,7 @@ require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kaptinlin/go-i18n v0.2.12 // indirect
github.com/kaptinlin/jsonpointer v0.4.17 // indirect
@@ -147,13 +153,17 @@ require (
github.com/muesli/roff v0.1.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
+ github.com/sergeymakinen/go-bmp v1.0.0 // indirect
+ github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
+ github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
@@ -22,6 +22,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
+git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=
+git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
github.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
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
+github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=
+github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA=
+github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.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=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.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=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
+github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.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
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
+github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o=
+github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.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
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
@@ -321,6 +336,10 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
+github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=
+github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
+github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=
+github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.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
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
+github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.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
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
@@ -32,12 +32,14 @@ import (
"charm.land/fantasy/providers/vercel"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/agent/hyper"
+ "github.com/charmbracelet/crush/internal/agent/notify"
"github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/permission"
+ "github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/stringext"
"github.com/charmbracelet/crush/internal/version"
@@ -73,6 +75,7 @@ type SessionAgentCall struct {
TopK *int64
FrequencyPenalty *float64
PresencePenalty *float64
+ NonInteractive bool
}
type SessionAgent interface {
@@ -109,6 +112,7 @@ type sessionAgent struct {
messages message.Service
disableAutoSummarize bool
isYolo bool
+ notify pubsub.Publisher[notify.Notification]
messageQueue *csync.Map[string, []SessionAgentCall]
activeRequests *csync.Map[string, context.CancelFunc]
@@ -125,6 +129,7 @@ type SessionAgentOptions struct {
Sessions session.Service
Messages message.Service
Tools []fantasy.AgentTool
+ Notify pubsub.Publisher[notify.Notification]
}
func NewSessionAgent(
@@ -141,6 +146,7 @@ func NewSessionAgent(
disableAutoSummarize: opts.DisableAutoSummarize,
tools: csync.NewSliceFrom(opts.Tools),
isYolo: opts.IsYolo,
+ notify: opts.Notify,
messageQueue: csync.NewMap[string, []SessionAgentCall](),
activeRequests: csync.NewMap[string, context.CancelFunc](),
}
@@ -532,6 +538,16 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
return nil, err
}
+ // Send notification that agent has finished its turn (skip for
+ // nested/non-interactive sessions).
+ if !call.NonInteractive && a.notify != nil {
+ a.notify.Publish(pubsub.CreatedEvent, notify.Notification{
+ SessionID: call.SessionID,
+ SessionTitle: currentSession.Title,
+ Type: notify.TypeAgentFinished,
+ })
+ }
+
if shouldSummarize {
a.activeRequests.Del(call.SessionID)
if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
@@ -153,7 +153,15 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro
DefaultMaxTokens: 10000,
},
}
- agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, false, true, env.sessions, env.messages, tools})
+ agent := NewSessionAgent(SessionAgentOptions{
+ LargeModel: largeModel,
+ SmallModel: smallModel,
+ SystemPrompt: systemPrompt,
+ IsYolo: true,
+ Sessions: env.sessions,
+ Messages: env.messages,
+ Tools: tools,
+ })
return agent
}
@@ -18,6 +18,7 @@ import (
"charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
"github.com/charmbracelet/crush/internal/agent/hyper"
+ "github.com/charmbracelet/crush/internal/agent/notify"
"github.com/charmbracelet/crush/internal/agent/prompt"
"github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/config"
@@ -28,6 +29,7 @@ import (
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/oauth/copilot"
"github.com/charmbracelet/crush/internal/permission"
+ "github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/session"
"golang.org/x/sync/errgroup"
@@ -79,6 +81,7 @@ type coordinator struct {
history history.Service
filetracker filetracker.Service
lspManager *lsp.Manager
+ notify pubsub.Publisher[notify.Notification]
currentAgent SessionAgent
agents map[string]SessionAgent
@@ -95,6 +98,7 @@ func NewCoordinator(
history history.Service,
filetracker filetracker.Service,
lspManager *lsp.Manager,
+ notify pubsub.Publisher[notify.Notification],
) (Coordinator, error) {
c := &coordinator{
cfg: cfg,
@@ -104,6 +108,7 @@ func NewCoordinator(
history: history,
filetracker: filetracker,
lspManager: lspManager,
+ notify: notify,
agents: make(map[string]SessionAgent),
}
@@ -380,16 +385,17 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
result := NewSessionAgent(SessionAgentOptions{
- large,
- small,
- largeProviderCfg.SystemPromptPrefix,
- "",
- isSubAgent,
- c.cfg.Options.DisableAutoSummarize,
- c.permissions.SkipRequests(),
- c.sessions,
- c.messages,
- nil,
+ LargeModel: large,
+ SmallModel: small,
+ SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix,
+ SystemPrompt: "",
+ IsSubAgent: isSubAgent,
+ DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
+ IsYolo: c.permissions.SkipRequests(),
+ Sessions: c.sessions,
+ Messages: c.messages,
+ Tools: nil,
+ Notify: c.notify,
})
c.readyWg.Go(func() error {
@@ -994,6 +1000,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f
TopK: model.ModelCfg.TopK,
FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
PresencePenalty: model.ModelCfg.PresencePenalty,
+ NonInteractive: true,
})
if err != nil {
return fantasy.NewTextErrorResponse("error generating response"), nil
@@ -0,0 +1,19 @@
+// Package notify defines domain notification types for agent events.
+// These types are decoupled from UI concerns so the agent can publish
+// events without importing UI packages.
+package notify
+
+// Type identifies the kind of agent notification.
+type Type string
+
+const (
+ // TypeAgentFinished indicates the agent has completed its turn.
+ TypeAgentFinished Type = "agent_finished"
+)
+
+// Notification represents a domain event published by the agent.
+type Notification struct {
+ SessionID string
+ SessionTitle string
+ Type Type
+}
@@ -19,6 +19,7 @@ import (
"charm.land/fantasy"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/agent"
+ "github.com/charmbracelet/crush/internal/agent/notify"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/db"
@@ -68,8 +69,9 @@ type App struct {
tuiWG *sync.WaitGroup
// global context and cleanup functions
- globalCtx context.Context
- cleanupFuncs []func(context.Context) error
+ globalCtx context.Context
+ cleanupFuncs []func(context.Context) error
+ agentNotifications *pubsub.Broker[notify.Notification]
}
// New initializes a new application instance.
@@ -96,9 +98,10 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
config: cfg,
- events: make(chan tea.Msg, 100),
- serviceEventsWG: &sync.WaitGroup{},
- tuiWG: &sync.WaitGroup{},
+ events: make(chan tea.Msg, 100),
+ serviceEventsWG: &sync.WaitGroup{},
+ tuiWG: &sync.WaitGroup{},
+ agentNotifications: pubsub.NewBroker[notify.Notification](),
}
app.setupEvents()
@@ -112,7 +115,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
app.cleanupFuncs = append(
app.cleanupFuncs,
func(context.Context) error { return conn.Close() },
- mcp.Close,
+ func(ctx context.Context) error { return mcp.Close(ctx) },
)
// TODO: remove the concept of agent config, most likely.
@@ -143,6 +146,11 @@ func (app *App) Config() *config.Config {
return app.config
}
+// AgentNotifications returns the broker for agent notification events.
+func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] {
+ return app.agentNotifications
+}
+
// RunNonInteractive runs the application in non-interactive mode with the
// given prompt, printing to stdout.
func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
@@ -414,6 +422,7 @@ func (app *App) setupEvents() {
setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
+ setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
cleanupFunc := func(context.Context) error {
@@ -486,6 +495,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
app.History,
app.FileTracker,
app.LSPManager,
+ app.agentNotifications,
)
if err != nil {
slog.Error("Failed to create coder agent", "err", err)
@@ -261,6 +261,7 @@ 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"`
}
type MCPs map[string]MCPConfig
@@ -134,6 +134,12 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
return true, nil
}
+ // Check if the tool/action combination is in the allowlist
+ commandKey := opts.ToolName + ":" + opts.Action
+ if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
+ return true, nil
+ }
+
// tell the UI that a permission was requested
s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
ToolCallID: opts.ToolCallID,
@@ -141,12 +147,6 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
s.requestMu.Lock()
defer s.requestMu.Unlock()
- // Check if the tool/action combination is in the allowlist
- commandKey := opts.ToolName + ":" + opts.Action
- if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
- return true, nil
- }
-
s.autoApproveSessionsMu.RLock()
autoApprove := s.autoApproveSessions[opts.SessionID]
s.autoApproveSessionsMu.RUnlock()
@@ -58,7 +58,7 @@ func (c *Capabilities) Update(msg any) {
}
case tea.TerminalVersionMsg:
c.TerminalVersion = m.Name
- case uv.ModeReportEvent:
+ case tea.ModeReportMsg:
switch m.Mode {
case ansi.ModeFocusEvent:
c.ReportFocusEvents = modeSupported(m.Value)
@@ -77,7 +77,7 @@ func QueryCmd(env uv.Environ) tea.Cmd {
shouldQueryFor := shouldQueryCapabilities(env)
if shouldQueryFor {
sb.WriteString(ansi.RequestNameVersion)
- // sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications.
+ 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 {
@@ -25,6 +25,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/agent/notify"
agenttools "github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/app"
@@ -45,6 +46,7 @@ import (
"github.com/charmbracelet/crush/internal/ui/dialog"
fimage "github.com/charmbracelet/crush/internal/ui/image"
"github.com/charmbracelet/crush/internal/ui/logo"
+ "github.com/charmbracelet/crush/internal/ui/notification"
"github.com/charmbracelet/crush/internal/ui/styles"
"github.com/charmbracelet/crush/internal/ui/util"
"github.com/charmbracelet/crush/internal/version"
@@ -201,6 +203,9 @@ type UI struct {
// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
sidebarLogo string
+ // Notification state
+ notifyBackend notification.Backend
+ notifyWindowFocused bool
// custom commands & mcp commands
customCommands []commands.CustomCommand
mcpPrompts []commands.MCPPrompt
@@ -280,17 +285,19 @@ func New(com *common.Common) *UI {
header := newHeader(com)
ui := &UI{
- com: com,
- dialog: dialog.NewOverlay(),
- keyMap: keyMap,
- textarea: ta,
- chat: ch,
- header: header,
- completions: comp,
- attachments: attachments,
- todoSpinner: todoSpinner,
- lspStates: make(map[string]app.LSPClientInfo),
- mcpStates: make(map[string]mcp.ClientInfo),
+ com: com,
+ dialog: dialog.NewOverlay(),
+ keyMap: keyMap,
+ textarea: ta,
+ chat: ch,
+ header: header,
+ completions: comp,
+ attachments: attachments,
+ todoSpinner: todoSpinner,
+ lspStates: make(map[string]app.LSPClientInfo),
+ mcpStates: make(map[string]mcp.ClientInfo),
+ notifyBackend: notification.NoopBackend{},
+ notifyWindowFocused: true,
}
status := NewStatus(com, ui)
@@ -342,6 +349,32 @@ func (m *UI) Init() tea.Cmd {
return tea.Batch(cmds...)
}
+// sendNotification returns a command that sends a notification if allowed by policy.
+func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
+ if !m.shouldSendNotification() {
+ 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 nil
+ }
+}
+
+// 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.
+func (m *UI) shouldSendNotification() bool {
+ cfg := m.com.Config()
+ if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
+ return false
+ }
+ return m.caps.ReportFocusEvents && !m.notifyWindowFocused
+}
+
// setState changes the UI state and focus.
func (m *UI) setState(state uiState, focus uiFocusState) {
if state == uiLanding {
@@ -397,6 +430,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
}
cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
+ case tea.ModeReportMsg:
+ if m.caps.ReportFocusEvents {
+ m.notifyBackend = notification.NewNativeBackend(notification.Icon)
+ }
+ case tea.FocusMsg:
+ m.notifyWindowFocused = true
+ case tea.BlurMsg:
+ m.notifyWindowFocused = false
+ case pubsub.Event[notify.Notification]:
+ if cmd := m.handleAgentNotification(msg.Payload); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
case loadSessionMsg:
if m.forceCompactMode {
m.isCompact = true
@@ -542,6 +587,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
cmds = append(cmds, cmd)
}
+ if cmd := m.sendNotification(notification.Notification{
+ Title: "Crush is waiting...",
+ Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
+ }); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
case pubsub.Event[permission.PermissionNotification]:
m.handlePermissionNotification(msg.Payload)
case cancelTimerExpiredMsg:
@@ -1964,6 +2015,7 @@ func (m *UI) View() tea.View {
v.BackgroundColor = m.com.Styles.Background
}
v.MouseMode = tea.MouseModeCellMotion
+ v.ReportFocus = m.caps.ReportFocusEvents
v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
canvas := uv.NewScreenBuffer(m.width, m.height)
@@ -2980,6 +3032,20 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
}
}
+// handleAgentNotification translates domain agent events into desktop
+// notifications using the UI notification backend.
+func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
+ switch n.Type {
+ case notify.TypeAgentFinished:
+ return m.sendNotification(notification.Notification{
+ Title: "Crush is waiting...",
+ Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
+ })
+ default:
+ return nil
+ }
+}
+
// newSession clears the current session state and prepares for a new session.
// The actual session creation happens when the user sends their first message.
// Returns a command to reload prompt history.
@@ -0,0 +1,7 @@
+//go:build darwin
+
+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 = ""
@@ -0,0 +1,13 @@
+//go:build !darwin
+
+package notification
+
+import (
+ _ "embed"
+)
+
+//go:embed crush-icon-solo.png
+var icon []byte
+
+// Icon contains the embedded PNG icon data for desktop notifications.
+var Icon any = icon
@@ -0,0 +1,49 @@
+package notification
+
+import (
+ "log/slog"
+
+ "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
+ // 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 {
+ beeep.AppName = "Crush"
+ return &NativeBackend{
+ icon: icon,
+ notifyFunc: beeep.Notify,
+ }
+}
+
+// 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)
+
+ 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")
+ }
+
+ return err
+}
+
+// SetNotifyFunc allows replacing the notification function for testing.
+func (b *NativeBackend) SetNotifyFunc(fn func(title, message string, icon any) error) {
+ b.notifyFunc = fn
+}
+
+// ResetNotifyFunc resets the notification function to the default.
+func (b *NativeBackend) ResetNotifyFunc() {
+ b.notifyFunc = beeep.Notify
+}
@@ -0,0 +1,10 @@
+package notification
+
+// 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 {
+ return nil
+}
@@ -0,0 +1,15 @@
+// Package notification provides desktop notification support for the UI.
+package notification
+
+// Notification represents a desktop notification request.
+type Notification struct {
+ Title string
+ Message string
+}
+
+// Backend defines the interface for sending desktop notifications.
+// Implementations are pure transport - policy decisions (config, focus state)
+// are handled by the caller.
+type Backend interface {
+ Send(n Notification) error
+}
@@ -0,0 +1,43 @@
+package notification_test
+
+import (
+ "testing"
+
+ "github.com/charmbracelet/crush/internal/ui/notification"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNoopBackend_Send(t *testing.T) {
+ t.Parallel()
+
+ backend := notification.NoopBackend{}
+ err := backend.Send(notification.Notification{
+ Title: "Test Title",
+ Message: "Test Message",
+ })
+ require.NoError(t, err)
+}
+
+func TestNativeBackend_Send(t *testing.T) {
+ t.Parallel()
+
+ backend := notification.NewNativeBackend(nil)
+
+ var capturedTitle, capturedMessage string
+ var capturedIcon any
+ backend.SetNotifyFunc(func(title, message string, icon any) error {
+ capturedTitle = title
+ capturedMessage = message
+ capturedIcon = icon
+ return nil
+ })
+
+ err := backend.Send(notification.Notification{
+ Title: "Hello",
+ Message: "World",
+ })
+ require.NoError(t, err)
+ require.Equal(t, "Hello", capturedTitle)
+ require.Equal(t, "World", capturedMessage)
+ require.Nil(t, capturedIcon)
+}
@@ -451,6 +451,11 @@
"type": "boolean",
"description": "Show indeterminate progress updates during long operations",
"default": true
+ },
+ "disable_notifications": {
+ "type": "boolean",
+ "description": "Disable desktop notifications",
+ "default": false
}
},
"additionalProperties": false,