feat(notification): completion/permission notifs

Amolith created

Change summary

README.md                                                  |  23 +
go.mod                                                     |   9 
go.sum                                                     |  24 +
internal/agent/agent.go                                    |  96 +++++
internal/agent/common_test.go                              |  16 
internal/agent/coordinator.go                              |  51 ++
internal/agent/tools/multiedit_test.go                     |   2 
internal/app/app.go                                        |  12 
internal/config/config.go                                  |   1 
internal/notification/notification.go                      |  77 ++++
internal/notification/notification_test.go                 |  74 ++++
internal/permission/permission.go                          |  83 ++++-
internal/permission/permission_test.go                     | 128 +++++++
internal/tui/components/chat/chat.go                       |  12 
internal/tui/components/chat/editor/editor.go              |  11 
internal/tui/components/dialogs/permissions/permissions.go |  47 ++
internal/tui/page/chat/chat.go                             |  31 +
internal/tui/tui.go                                        |  17 +
schema.json                                                |   5 
19 files changed, 669 insertions(+), 50 deletions(-)

Detailed changes

README.md 🔗

@@ -605,6 +605,29 @@ config:
 }
 ```
 
+## Desktop Notifications
+
+Crush defaults to sending desktop notifications that let you know:
+
+- When its turn is finished (automatically cancelled when you interact with the
+  chat interface)
+- When it's waiting for permission to execute a tool (automatically cancelled
+  when you interact with the permission dialog)
+
+### Disabling notifications
+
+If you prefer to work without desktop notifications, you can disable them in
+your `crush.json` config.
+
+```json
+{
+  "$schema": "https://charm.land/crush.json",
+  "options": {
+    "disable_notifications": true
+  }
+}
+```
+
 ## Provider Auto-Updates
 
 By default, Crush automatically checks for the latest and greatest list of

go.mod 🔗

@@ -31,6 +31,7 @@ require (
 	github.com/charmbracelet/x/term v0.2.2
 	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
+	github.com/gen2brain/beeep v0.11.1
 	github.com/google/uuid v1.6.0
 	github.com/invopop/jsonschema v0.13.0
 	github.com/joho/godotenv v1.5.1
@@ -65,6 +66,7 @@ require (
 	cloud.google.com/go/auth v0.17.0 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
 	cloud.google.com/go/compute/metadata v0.8.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/RealAlexandreAI/json-repair v0.0.14 // indirect
@@ -98,14 +100,17 @@ require (
 	github.com/disintegration/gift v1.1.2 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/dustin/go-humanize v1.0.1 // 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-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
 	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.4.0 // indirect
 	github.com/goccy/go-yaml v1.18.0 // indirect
+	github.com/godbus/dbus/v5 v5.1.0 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/google/jsonschema-go v0.3.0 // indirect
 	github.com/google/s2a-go v0.1.9 // indirect
@@ -115,6 +120,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/kaptinlin/go-i18n v0.2.0 // indirect
 	github.com/kaptinlin/jsonpointer v0.4.6 // indirect
 	github.com/kaptinlin/jsonschema v0.6.1 // indirect
@@ -136,10 +142,13 @@ require (
 	github.com/ncruces/julianday v1.0.0 // indirect
 	github.com/pierrec/lz4/v4 v4.1.22 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/sergeymakinen/go-bmp v1.0.0 // indirect
+	github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 	github.com/sethvargo/go-retry v0.3.0 // indirect
 	github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
+	github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
 	github.com/tetratelabs/wazero v1.10.0 // indirect
 	github.com/tidwall/gjson v1.18.0 // indirect
 	github.com/tidwall/match v1.1.1 // indirect

go.sum 🔗

@@ -16,6 +16,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.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
 cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
+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=
@@ -142,11 +144,15 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+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.1 h1:EbSIhrQZFDj1K2fzlMpAYlFOzV8YuNe721A58XcCTYI=
+github.com/gen2brain/beeep v0.11.1/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc=
 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
@@ -156,12 +162,16 @@ 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.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
 github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
 github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -192,6 +202,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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
@@ -286,6 +298,10 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
 github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
+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=
@@ -303,11 +319,18 @@ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS
 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
 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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+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.10.0 h1:CXP3zneLDl6J4Zy8N/J+d5JsWKfrjE6GtvVK1fpnDlk=
 github.com/tetratelabs/wazero v1.10.0/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
 github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -408,6 +431,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

internal/agent/agent.go 🔗

@@ -31,6 +31,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/notification"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/stringext"
@@ -67,8 +68,16 @@ type SessionAgent interface {
 	ClearQueue(sessionID string)
 	Summarize(context.Context, string, fantasy.ProviderOptions) error
 	Model() Model
+	// CancelCompletionNotification cancels any scheduled "turn ended"
+	// notification for the provided sessionID.
+	CancelCompletionNotification(sessionID string)
+	// HasPendingCompletionNotification returns true if a turn-end
+	// notification has been scheduled and not yet cancelled/shown.
+	HasPendingCompletionNotification(sessionID string) bool
 }
 
+const completionNotificationDelay = 5 * time.Second
+
 type Model struct {
 	Model      fantasy.LanguageModel
 	CatwalkCfg catwalk.Model
@@ -86,8 +95,11 @@ type sessionAgent struct {
 	disableAutoSummarize bool
 	isYolo               bool
 
-	messageQueue   *csync.Map[string, []SessionAgentCall]
-	activeRequests *csync.Map[string, context.CancelFunc]
+	messageQueue      *csync.Map[string, []SessionAgentCall]
+	activeRequests    *csync.Map[string, context.CancelFunc]
+	notifier          *notification.Notifier
+	notifyCtx         context.Context
+	completionCancels *csync.Map[string, context.CancelFunc]
 }
 
 type SessionAgentOptions struct {
@@ -100,11 +112,18 @@ type SessionAgentOptions struct {
 	Sessions             session.Service
 	Messages             message.Service
 	Tools                []fantasy.AgentTool
+	Notifier             *notification.Notifier
+	NotificationCtx      context.Context
 }
 
 func NewSessionAgent(
 	opts SessionAgentOptions,
 ) SessionAgent {
+	notifyCtx := opts.NotificationCtx
+	if notifyCtx == nil {
+		notifyCtx = context.Background()
+	}
+
 	return &sessionAgent{
 		largeModel:           opts.LargeModel,
 		smallModel:           opts.SmallModel,
@@ -117,6 +136,9 @@ func NewSessionAgent(
 		isYolo:               opts.IsYolo,
 		messageQueue:         csync.NewMap[string, []SessionAgentCall](),
 		activeRequests:       csync.NewMap[string, context.CancelFunc](),
+		notifier:             opts.Notifier,
+		notifyCtx:            notifyCtx,
+		completionCancels:    csync.NewMap[string, context.CancelFunc](),
 	}
 }
 
@@ -128,6 +150,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 		return nil, ErrSessionMissing
 	}
 
+	a.cancelCompletionNotification(call.SessionID)
+
 	// Queue the message if busy
 	if a.IsSessionBusy(call.SessionID) {
 		existing, ok := a.messageQueue.Get(call.SessionID)
@@ -373,6 +397,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			if sessionErr != nil {
 				return sessionErr
 			}
+			if finishReason == message.FinishReasonEndTurn {
+				a.scheduleCompletionNotification(call.SessionID, currentSession.Title)
+			}
 			return a.messages.Update(genCtx, *currentAssistant)
 		},
 		StopWhen: []fantasy.StopCondition{
@@ -486,6 +513,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 	wg.Wait()
 
 	if shouldSummarize {
+		a.cancelCompletionNotification(call.SessionID)
 		a.activeRequests.Del(call.SessionID)
 		if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
 			return nil, summarizeErr
@@ -516,6 +544,56 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 	return a.Run(ctx, firstQueuedMessage)
 }
 
+func (a *sessionAgent) scheduleCompletionNotification(sessionID, sessionTitle string) {
+	// Do not emit notifications for Agent-tool sub-sessions.
+	if a.sessions != nil && a.sessions.IsAgentToolSession(sessionID) {
+		return
+	}
+	if a.notifier == nil {
+		return
+	}
+
+	if sessionTitle == "" {
+		sessionTitle = sessionID
+	}
+
+	if cancel, ok := a.completionCancels.Take(sessionID); ok && cancel != nil {
+		cancel()
+	}
+
+	title := "💘 Crush is waiting"
+	message := fmt.Sprintf("Agent's turn completed in session \"%s\"", sessionTitle)
+	cancel := a.notifier.NotifyTaskComplete(a.notifyCtx, title, message, completionNotificationDelay)
+	if cancel == nil {
+		cancel = func() {}
+	}
+	a.completionCancels.Set(sessionID, cancel)
+}
+
+func (a *sessionAgent) cancelCompletionNotification(sessionID string) {
+	if a.notifier == nil {
+		return
+	}
+
+	if cancel, ok := a.completionCancels.Take(sessionID); ok && cancel != nil {
+		cancel()
+	}
+}
+
+// CancelCompletionNotification implements SessionAgent.
+func (a *sessionAgent) CancelCompletionNotification(sessionID string) {
+	a.cancelCompletionNotification(sessionID)
+}
+
+// HasPendingCompletionNotification implements SessionAgent.
+func (a *sessionAgent) HasPendingCompletionNotification(sessionID string) bool {
+	if a.IsSessionBusy(sessionID) {
+		return false
+	}
+	_, ok := a.completionCancels.Get(sessionID)
+	return ok
+}
+
 func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fantasy.ProviderOptions) error {
 	if a.IsSessionBusy(sessionID) {
 		return ErrSessionBusy
@@ -829,6 +907,13 @@ func (a *sessionAgent) ClearQueue(sessionID string) {
 
 func (a *sessionAgent) CancelAll() {
 	if !a.IsBusy() {
+		// still ensure notifications are cancelled even when not busy
+		for cancel := range a.completionCancels.Seq() {
+			if cancel != nil {
+				cancel()
+			}
+		}
+		a.completionCancels.Reset(make(map[string]context.CancelFunc))
 		return
 	}
 	for key := range a.activeRequests.Seq2() {
@@ -844,6 +929,13 @@ func (a *sessionAgent) CancelAll() {
 			time.Sleep(200 * time.Millisecond)
 		}
 	}
+
+	for cancel := range a.completionCancels.Seq() {
+		if cancel != nil {
+			cancel()
+		}
+	}
+	a.completionCancels.Reset(make(map[string]context.CancelFunc))
 }
 
 func (a *sessionAgent) IsBusy() bool {

internal/agent/common_test.go 🔗

@@ -115,7 +115,7 @@ func testEnv(t *testing.T) fakeEnv {
 	sessions := session.NewService(q)
 	messages := message.NewService(q)
 
-	permissions := permission.NewPermissionService(workingDir, true, []string{})
+	permissions := permission.NewPermissionService(t.Context(), workingDir, true, []string{}, nil)
 	history := history.NewService(q, conn)
 	lspClients := csync.NewMap[string, *lsp.Client]()
 
@@ -149,7 +149,19 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro
 			DefaultMaxTokens: 10000,
 		},
 	}
-	agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, true, env.sessions, env.messages, tools})
+	agent := NewSessionAgent(SessionAgentOptions{
+		LargeModel:           largeModel,
+		SmallModel:           smallModel,
+		SystemPromptPrefix:   "",
+		SystemPrompt:         systemPrompt,
+		DisableAutoSummarize: false,
+		IsYolo:               true,
+		Sessions:             env.sessions,
+		Messages:             env.messages,
+		Tools:                tools,
+		Notifier:             nil,
+		NotificationCtx:      context.Background(),
+	})
 	return agent
 }
 

internal/agent/coordinator.go 🔗

@@ -24,6 +24,7 @@ import (
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/notification"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/session"
 	"golang.org/x/sync/errgroup"
@@ -52,6 +53,12 @@ type Coordinator interface {
 	Summarize(context.Context, string) error
 	Model() Model
 	UpdateModels(ctx context.Context) error
+	// CancelCompletionNotification cancels any scheduled "turn ended"
+	// notification for the provided sessionID.
+	CancelCompletionNotification(sessionID string)
+	// HasPendingCompletionNotification reports whether there is a pending
+	// turn-end notification for the given session.
+	HasPendingCompletionNotification(sessionID string) bool
 }
 
 type coordinator struct {
@@ -61,6 +68,8 @@ type coordinator struct {
 	permissions permission.Service
 	history     history.Service
 	lspClients  *csync.Map[string, *lsp.Client]
+	notifier    *notification.Notifier
+	notifyCtx   context.Context
 
 	currentAgent SessionAgent
 	agents       map[string]SessionAgent
@@ -76,7 +85,11 @@ func NewCoordinator(
 	permissions permission.Service,
 	history history.Service,
 	lspClients *csync.Map[string, *lsp.Client],
+	notifier *notification.Notifier,
 ) (Coordinator, error) {
+	if ctx == nil {
+		ctx = context.Background()
+	}
 	c := &coordinator{
 		cfg:         cfg,
 		sessions:    sessions,
@@ -84,6 +97,8 @@ func NewCoordinator(
 		permissions: permissions,
 		history:     history,
 		lspClients:  lspClients,
+		notifier:    notifier,
+		notifyCtx:   ctx,
 		agents:      make(map[string]SessionAgent),
 	}
 
@@ -144,6 +159,22 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 	})
 }
 
+// CancelCompletionNotification implements Coordinator.
+func (c *coordinator) CancelCompletionNotification(sessionID string) {
+	if c.currentAgent == nil || sessionID == "" {
+		return
+	}
+	c.currentAgent.CancelCompletionNotification(sessionID)
+}
+
+// HasPendingCompletionNotification implements Coordinator.
+func (c *coordinator) HasPendingCompletionNotification(sessionID string) bool {
+	if c.currentAgent == nil || sessionID == "" {
+		return false
+	}
+	return c.currentAgent.HasPendingCompletionNotification(sessionID)
+}
+
 func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
 	options := fantasy.ProviderOptions{}
 
@@ -287,15 +318,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,
-		systemPrompt,
-		c.cfg.Options.DisableAutoSummarize,
-		c.permissions.SkipRequests(),
-		c.sessions,
-		c.messages,
-		nil,
+		LargeModel:           large,
+		SmallModel:           small,
+		SystemPromptPrefix:   largeProviderCfg.SystemPromptPrefix,
+		SystemPrompt:         systemPrompt,
+		DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
+		IsYolo:               c.permissions.SkipRequests(),
+		Sessions:             c.sessions,
+		Messages:             c.messages,
+		Tools:                nil,
+		Notifier:             c.notifier,
+		NotificationCtx:      c.notifyCtx,
 	})
 	c.readyWg.Go(func() error {
 		tools, err := c.buildTools(ctx, agent)

internal/agent/tools/multiedit_test.go 🔗

@@ -40,6 +40,8 @@ func (m *mockPermissionService) SubscribeNotifications(ctx context.Context) <-ch
 	return make(<-chan pubsub.Event[permission.PermissionNotification])
 }
 
+func (m *mockPermissionService) NotifyInteraction(toolCallID string) {}
+
 type mockHistoryService struct {
 	*pubsub.Broker[history.File]
 }

internal/app/app.go 🔗

@@ -26,6 +26,7 @@ import (
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/notification"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
@@ -46,6 +47,7 @@ type App struct {
 	Permissions permission.Service
 
 	AgentCoordinator agent.Coordinator
+	Notifier         *notification.Notifier
 
 	LSPClients *csync.Map[string, *lsp.Client]
 
@@ -73,11 +75,18 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 		allowedTools = cfg.Permissions.AllowedTools
 	}
 
+	// Enable notifications by default, disable if configured.
+	notificationsEnabled := cfg.Options == nil || !cfg.Options.DisableNotifications
+
+	notifier := notification.New(notificationsEnabled)
+	permissions := permission.NewPermissionService(ctx, cfg.WorkingDir(), skipPermissionsRequests, allowedTools, notifier)
+
 	app := &App{
 		Sessions:    sessions,
 		Messages:    messages,
 		History:     files,
-		Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
+		Permissions: permissions,
+		Notifier:    notifier,
 		LSPClients:  csync.NewMap[string, *lsp.Client](),
 
 		globalCtx: ctx,
@@ -328,6 +337,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
 		app.Permissions,
 		app.History,
 		app.LSPClients,
+		app.Notifier,
 	)
 	if err != nil {
 		slog.Error("Failed to create coder agent", "err", err)

internal/config/config.go 🔗

@@ -204,6 +204,7 @@ type Options struct {
 	Attribution               *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
 	DisableMetrics            bool         `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
 	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"`
+	DisableNotifications      bool         `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
 }
 
 type MCPs map[string]MCPConfig

internal/notification/notification.go 🔗

@@ -0,0 +1,77 @@
+package notification
+
+import (
+	"context"
+	"log/slog"
+	"time"
+
+	"github.com/gen2brain/beeep"
+)
+
+// Notifier handles sending native notifications.
+type Notifier struct {
+	enabled bool
+}
+
+// New creates a new Notifier instance.
+func New(enabled bool) *Notifier {
+	return &Notifier{
+		enabled: enabled,
+	}
+}
+
+// NotifyTaskComplete sends a notification when a task is completed.
+// It waits for the specified delay before sending the notification.
+func (n *Notifier) NotifyTaskComplete(ctx context.Context, title, message string, delay time.Duration) context.CancelFunc {
+	if !n.enabled {
+		slog.Debug("Notifications disabled, skipping completion notification")
+		return func() {}
+	}
+
+	notifyCtx, cancel := context.WithCancel(ctx)
+	go func() {
+		slog.Debug("Waiting before sending completion notification", "delay", delay)
+		timer := time.NewTimer(delay)
+		defer timer.Stop()
+		select {
+		case <-timer.C:
+			slog.Debug("Sending completion notification", "title", title, "message", message)
+			if err := beeep.Notify(title, message, ""); err != nil {
+				slog.Warn("Failed to send notification", "error", err, "title", title, "message", message)
+			} else {
+				slog.Debug("Notification sent successfully", "title", title)
+			}
+		case <-notifyCtx.Done():
+			slog.Debug("Completion notification cancelled")
+		}
+	}()
+	return cancel
+}
+
+// NotifyPermissionRequest sends a notification when a permission request needs attention.
+// It waits for the specified delay before sending the notification.
+func (n *Notifier) NotifyPermissionRequest(ctx context.Context, title, message string, delay time.Duration) context.CancelFunc {
+	if !n.enabled {
+		slog.Debug("Notifications disabled, skipping permission request notification")
+		return func() {}
+	}
+
+	notifyCtx, cancel := context.WithCancel(ctx)
+	go func() {
+		slog.Debug("Waiting before sending permission request notification", "delay", delay)
+		timer := time.NewTimer(delay)
+		defer timer.Stop()
+		select {
+		case <-timer.C:
+			slog.Debug("Sending permission request notification", "title", title, "message", message)
+			if err := beeep.Notify(title, message, ""); err != nil {
+				slog.Warn("Failed to send notification", "error", err, "title", title, "message", message)
+			} else {
+				slog.Debug("Notification sent successfully", "title", title)
+			}
+		case <-notifyCtx.Done():
+			slog.Debug("Permission request notification cancelled")
+		}
+	}()
+	return cancel
+}

internal/notification/notification_test.go 🔗

@@ -0,0 +1,74 @@
+package notification
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestNew(t *testing.T) {
+	t.Parallel()
+
+	t.Run("creates notifier with notifications enabled", func(t *testing.T) {
+		t.Parallel()
+		n := New(true)
+		require.NotNil(t, n)
+		require.True(t, n.enabled)
+	})
+
+	t.Run("creates notifier with notifications disabled", func(t *testing.T) {
+		t.Parallel()
+		n := New(false)
+		require.NotNil(t, n)
+		require.False(t, n.enabled)
+	})
+}
+
+func TestNotifyTaskComplete(t *testing.T) {
+	t.Parallel()
+
+	t.Run("does not panic when notifications enabled", func(t *testing.T) {
+		t.Parallel()
+		ctx := context.Background()
+		n := New(true)
+		require.NotPanics(t, func() {
+			cancel := n.NotifyTaskComplete(ctx, "Test Title", "Test Message", 0)
+			defer cancel()
+		})
+	})
+
+	t.Run("does not panic when notifications disabled", func(t *testing.T) {
+		t.Parallel()
+		ctx := context.Background()
+		n := New(false)
+		require.NotPanics(t, func() {
+			cancel := n.NotifyTaskComplete(ctx, "Test Title", "Test Message", 0)
+			defer cancel()
+		})
+	})
+}
+
+func TestNotifyPermissionRequest(t *testing.T) {
+	t.Parallel()
+
+	t.Run("does not panic when notifications enabled", func(t *testing.T) {
+		t.Parallel()
+		ctx := context.Background()
+		n := New(true)
+		require.NotPanics(t, func() {
+			cancel := n.NotifyPermissionRequest(ctx, "Test Title", "Test Message", 0)
+			defer cancel()
+		})
+	})
+
+	t.Run("does not panic when notifications disabled", func(t *testing.T) {
+		t.Parallel()
+		ctx := context.Background()
+		n := New(false)
+		require.NotPanics(t, func() {
+			cancel := n.NotifyPermissionRequest(ctx, "Test Title", "Test Message", 0)
+			defer cancel()
+		})
+	})
+}

internal/permission/permission.go 🔗

@@ -3,10 +3,12 @@ package permission
 import (
 	"context"
 	"errors"
+	"fmt"
 	"os"
 	"path/filepath"
 	"slices"
 	"sync"
+	"time"
 
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/pubsub"
@@ -29,6 +31,7 @@ type PermissionNotification struct {
 	ToolCallID string `json:"tool_call_id"`
 	Granted    bool   `json:"granted"`
 	Denied     bool   `json:"denied"`
+	Interacted bool   `json:"interacted"` // true when user scrolls or navigates the dialog
 }
 
 type PermissionRequest struct {
@@ -52,12 +55,22 @@ type Service interface {
 	SetSkipRequests(skip bool)
 	SkipRequests() bool
 	SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification]
+	NotifyInteraction(toolCallID string)
 }
 
+type permissionNotifier interface {
+	NotifyPermissionRequest(ctx context.Context, title, message string, delay time.Duration) context.CancelFunc
+}
+
+const permissionNotificationDelay = 5 * time.Second
+
 type permissionService struct {
 	*pubsub.Broker[PermissionRequest]
 
 	notificationBroker    *pubsub.Broker[PermissionNotification]
+	notifier              permissionNotifier
+	notificationCtx       context.Context
+	notificationCancels   *csync.Map[string, context.CancelFunc]
 	workingDir            string
 	sessionPermissions    []PermissionRequest
 	sessionPermissionsMu  sync.RWMutex
@@ -73,6 +86,7 @@ type permissionService struct {
 }
 
 func (s *permissionService) GrantPersistent(permission PermissionRequest) {
+	s.cancelPermissionNotification(permission.ToolCallID)
 	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
 		ToolCallID: permission.ToolCallID,
 		Granted:    true,
@@ -92,6 +106,7 @@ func (s *permissionService) GrantPersistent(permission PermissionRequest) {
 }
 
 func (s *permissionService) Grant(permission PermissionRequest) {
+	s.cancelPermissionNotification(permission.ToolCallID)
 	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
 		ToolCallID: permission.ToolCallID,
 		Granted:    true,
@@ -107,6 +122,7 @@ func (s *permissionService) Grant(permission PermissionRequest) {
 }
 
 func (s *permissionService) Deny(permission PermissionRequest) {
+	s.cancelPermissionNotification(permission.ToolCallID)
 	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
 		ToolCallID: permission.ToolCallID,
 		Granted:    false,
@@ -127,6 +143,12 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
 		return true
 	}
 
+	// 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
+	}
+
 	// tell the UI that a permission was requested
 	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
 		ToolCallID: opts.ToolCallID,
@@ -134,12 +156,6 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
 	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
-	}
-
 	s.autoApproveSessionsMu.RLock()
 	autoApprove := s.autoApproveSessions[opts.SessionID]
 	s.autoApproveSessionsMu.RUnlock()
@@ -181,21 +197,14 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
 	}
 	s.sessionPermissionsMu.RUnlock()
 
-	s.sessionPermissionsMu.RLock()
-	for _, p := range s.sessionPermissions {
-		if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
-			s.sessionPermissionsMu.RUnlock()
-			return true
-		}
-	}
-	s.sessionPermissionsMu.RUnlock()
-
 	s.activeRequest = &permission
 
 	respCh := make(chan bool, 1)
 	s.pendingRequests.Set(permission.ID, respCh)
 	defer s.pendingRequests.Del(permission.ID)
 
+	s.schedulePermissionNotification(permission)
+
 	// Publish the request
 	s.Publish(pubsub.CreatedEvent, permission)
 
@@ -212,6 +221,14 @@ func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan p
 	return s.notificationBroker.Subscribe(ctx)
 }
 
+func (s *permissionService) NotifyInteraction(toolCallID string) {
+	s.cancelPermissionNotification(toolCallID)
+	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
+		ToolCallID: toolCallID,
+		Interacted: true,
+	})
+}
+
 func (s *permissionService) SetSkipRequests(skip bool) {
 	s.skip = skip
 }
@@ -220,10 +237,16 @@ func (s *permissionService) SkipRequests() bool {
 	return s.skip
 }
 
-func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
+func NewPermissionService(ctx context.Context, workingDir string, skip bool, allowedTools []string, notifier permissionNotifier) Service {
+	if ctx == nil {
+		ctx = context.Background()
+	}
 	return &permissionService{
 		Broker:              pubsub.NewBroker[PermissionRequest](),
 		notificationBroker:  pubsub.NewBroker[PermissionNotification](),
+		notifier:            notifier,
+		notificationCtx:     ctx,
+		notificationCancels: csync.NewMap[string, context.CancelFunc](),
 		workingDir:          workingDir,
 		sessionPermissions:  make([]PermissionRequest, 0),
 		autoApproveSessions: make(map[string]bool),
@@ -232,3 +255,31 @@ func NewPermissionService(workingDir string, skip bool, allowedTools []string) S
 		pendingRequests:     csync.NewMap[string, chan bool](),
 	}
 }
+
+func (s *permissionService) schedulePermissionNotification(permission PermissionRequest) {
+	if s.notifier == nil {
+		return
+	}
+
+	if cancel, ok := s.notificationCancels.Take(permission.ToolCallID); ok && cancel != nil {
+		cancel()
+	}
+
+	title := "💘 Crush is waiting"
+	message := fmt.Sprintf("Permission required to execute \"%s\"", permission.ToolName)
+	cancel := s.notifier.NotifyPermissionRequest(s.notificationCtx, title, message, permissionNotificationDelay)
+	if cancel == nil {
+		cancel = func() {}
+	}
+	s.notificationCancels.Set(permission.ToolCallID, cancel)
+}
+
+func (s *permissionService) cancelPermissionNotification(toolCallID string) {
+	if s.notifier == nil {
+		return
+	}
+
+	if cancel, ok := s.notificationCancels.Take(toolCallID); ok && cancel != nil {
+		cancel()
+	}
+}

internal/permission/permission_test.go 🔗

@@ -1,10 +1,13 @@
 package permission
 
 import (
+	"context"
 	"sync"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestPermissionService_AllowedCommands(t *testing.T) {
@@ -54,7 +57,7 @@ func TestPermissionService_AllowedCommands(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			service := NewPermissionService("/tmp", false, tt.allowedTools)
+			service := NewPermissionService(t.Context(), "/tmp", false, tt.allowedTools, nil)
 
 			// Create a channel to capture the permission request
 			// Since we're testing the allowlist logic, we need to simulate the request
@@ -79,7 +82,7 @@ func TestPermissionService_AllowedCommands(t *testing.T) {
 }
 
 func TestPermissionService_SkipMode(t *testing.T) {
-	service := NewPermissionService("/tmp", true, []string{})
+	service := NewPermissionService(t.Context(), "/tmp", true, []string{}, nil)
 
 	result := service.Request(CreatePermissionRequest{
 		SessionID:   "test-session",
@@ -96,7 +99,7 @@ func TestPermissionService_SkipMode(t *testing.T) {
 
 func TestPermissionService_SequentialProperties(t *testing.T) {
 	t.Run("Sequential permission requests with persistent grants", func(t *testing.T) {
-		service := NewPermissionService("/tmp", false, []string{})
+		service := NewPermissionService(t.Context(), "/tmp", false, []string{}, nil)
 
 		req1 := CreatePermissionRequest{
 			SessionID:   "session1",
@@ -140,7 +143,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
 		assert.True(t, result2, "Second request should be auto-approved")
 	})
 	t.Run("Sequential requests with temporary grants", func(t *testing.T) {
-		service := NewPermissionService("/tmp", false, []string{})
+		service := NewPermissionService(t.Context(), "/tmp", false, []string{}, nil)
 
 		req := CreatePermissionRequest{
 			SessionID:   "session2",
@@ -180,12 +183,11 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
 		assert.False(t, result2, "Second request should be denied")
 	})
 	t.Run("Concurrent requests with different outcomes", func(t *testing.T) {
-		service := NewPermissionService("/tmp", false, []string{})
+		service := NewPermissionService(t.Context(), "/tmp", false, []string{}, nil)
 
 		events := service.Subscribe(t.Context())
 
 		var wg sync.WaitGroup
-		results := make([]bool, 0)
 
 		requests := []CreatePermissionRequest{
 			{
@@ -211,11 +213,13 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
 			},
 		}
 
+		results := make([]bool, len(requests))
+
 		for i, req := range requests {
 			wg.Add(1)
 			go func(index int, request CreatePermissionRequest) {
 				defer wg.Done()
-				results = append(results, service.Request(request))
+				results[index] = service.Request(request)
 			}(i, req)
 		}
 
@@ -245,3 +249,113 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
 		assert.True(t, result, "Repeated request should be auto-approved due to persistent permission")
 	})
 }
+
+func TestPermissionService_NotifierSchedulesAndCancelsOnGrant(t *testing.T) {
+	notifier := &testPermissionNotifier{}
+	service := NewPermissionService(t.Context(), "/tmp", false, []string{}, notifier)
+
+	events := service.Subscribe(t.Context())
+	req := CreatePermissionRequest{
+		SessionID:  "session-grant",
+		ToolCallID: "tool-call-grant",
+		ToolName:   "bash",
+		Action:     "execute",
+		Path:       "/tmp/script.sh",
+	}
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		result := service.Request(req)
+		require.True(t, result, "Request should be granted")
+	}()
+
+	event := <-events
+	calls := notifier.Calls()
+	require.Len(t, calls, 1)
+	assert.Equal(t, "💘 Crush is waiting", calls[0].title)
+	assert.Equal(t, "Permission required to execute \"bash\"", calls[0].message)
+	assert.Equal(t, permissionNotificationDelay, calls[0].delay)
+
+	service.Grant(event.Payload)
+	wg.Wait()
+
+	calls = notifier.Calls()
+	require.Len(t, calls, 1)
+	assert.Equal(t, 1, calls[0].cancelCount)
+}
+
+func TestPermissionService_NotifyInteractionCancelsNotification(t *testing.T) {
+	notifier := &testPermissionNotifier{}
+	service := NewPermissionService(t.Context(), "/tmp", false, []string{}, notifier)
+
+	events := service.Subscribe(t.Context())
+	req := CreatePermissionRequest{
+		SessionID:  "session-interact",
+		ToolCallID: "tool-call-interact",
+		ToolName:   "edit",
+		Action:     "write",
+		Path:       "/tmp/file.txt",
+	}
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		result := service.Request(req)
+		require.False(t, result, "Request should be denied")
+	}()
+
+	event := <-events
+	calls := notifier.Calls()
+	require.Len(t, calls, 1)
+
+	service.NotifyInteraction(event.Payload.ToolCallID)
+	calls = notifier.Calls()
+	require.Len(t, calls, 1)
+	assert.Equal(t, 1, calls[0].cancelCount)
+
+	service.Deny(event.Payload)
+	wg.Wait()
+}
+
+type notificationCall struct {
+	title       string
+	message     string
+	delay       time.Duration
+	cancelCount int
+}
+
+type testPermissionNotifier struct {
+	mu    sync.Mutex
+	calls []*notificationCall
+}
+
+func (n *testPermissionNotifier) NotifyPermissionRequest(ctx context.Context, title, message string, delay time.Duration) context.CancelFunc {
+	call := &notificationCall{
+		title:   title,
+		message: message,
+		delay:   delay,
+	}
+	n.mu.Lock()
+	n.calls = append(n.calls, call)
+	n.mu.Unlock()
+
+	return func() {
+		n.mu.Lock()
+		call.cancelCount++
+		n.mu.Unlock()
+	}
+}
+
+func (n *testPermissionNotifier) Calls() []notificationCall {
+	n.mu.Lock()
+	defer n.mu.Unlock()
+
+	result := make([]notificationCall, len(n.calls))
+	for i, call := range n.calls {
+		result[i] = *call
+	}
+	return result
+}

internal/tui/components/chat/chat.go 🔗

@@ -130,6 +130,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			return m, nil // Ignore clicks outside the component
 		}
 		if msg.Button == tea.MouseLeft {
+			if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+				m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+			}
 			cmds = append(cmds, m.handleMouseClick(x, y))
 			return m, tea.Batch(cmds...)
 		}
@@ -149,6 +152,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			return m, nil // Ignore clicks outside the component
 		}
 		if msg.Button == tea.MouseLeft {
+			if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+				m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+			}
 			m.listCmp.EndSelection(x, y)
 		}
 		return m, tea.Batch(cmds...)
@@ -186,6 +192,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			if msg.endSelection {
 				m.listCmp.EndSelection(msg.x, msg.y)
 			}
+			if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+				m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+			}
 			m.listCmp.SelectionStop()
 			cmds = append(cmds, m.CopySelectedText(true))
 			return m, tea.Batch(cmds...)
@@ -208,6 +217,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		return m, tea.Batch(cmds...)
 
 	case tea.MouseWheelMsg:
+		if m.app != nil && m.app.AgentCoordinator != nil && m.session.ID != "" && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+			m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+		}
 		u, cmd := m.listCmp.Update(msg)
 		m.listCmp = u.(list.List[list.Item])
 		cmds = append(cmds, cmd)

internal/tui/components/chat/editor/editor.go 🔗

@@ -215,11 +215,18 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
 			return m, util.ReportWarn("Agent is working, please wait...")
 		}
+		if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+			m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+		}
 		return m, m.openEditor(m.textarea.Value())
 	case OpenEditorMsg:
 		m.textarea.SetValue(msg.Text)
 		m.textarea.MoveToEnd()
 	case tea.PasteMsg:
+		// Interaction: cancel any pending turn-end notification for this session.
+		if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+			m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+		}
 		path := strings.ReplaceAll(msg.Content, "\\ ", " ")
 		// try to get an image
 		path, err := filepath.Abs(strings.TrimSpace(path))
@@ -261,6 +268,10 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		m.setEditorPrompt()
 		return m, nil
 	case tea.KeyPressMsg:
+		// Interaction: cancel any pending turn-end notification for this session.
+		if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+			m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+		}
 		cur := m.textarea.Cursor()
 		curIdx := m.textarea.Width()*cur.Y + cur.X
 		switch {

internal/tui/components/dialogs/permissions/permissions.go 🔗

@@ -37,6 +37,11 @@ type PermissionResponseMsg struct {
 	Action     PermissionAction
 }
 
+// PermissionInteractionMsg signals that the user has interacted with the permission dialog
+type PermissionInteractionMsg struct {
+	ToolCallID string
+}
+
 // PermissionDialogCmp interface for permission dialog component
 type PermissionDialogCmp interface {
 	dialogs.DialogModel
@@ -68,6 +73,11 @@ type permissionDialogCmp struct {
 	finalDialogHeight int
 
 	keyMap KeyMap
+
+	// hasInteracted prevents a stream of redundant interaction events for every scroll
+	// when we only care about the first event for notification purposes. If we later care
+	// about all interaction events, maybe the flag should be removed.
+	hasInteracted bool
 }
 
 func NewPermissionDialogCmp(permission permission.PermissionRequest, opts *Options) PermissionDialogCmp {
@@ -97,6 +107,7 @@ func (p *permissionDialogCmp) supportsDiffView() bool {
 
 func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
+	var interactionCmd tea.Cmd
 
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
@@ -106,29 +117,29 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		cmd := p.SetSize()
 		cmds = append(cmds, cmd)
 	case tea.KeyPressMsg:
+		interactionCmd = p.emitInteraction()
 		switch {
 		case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab):
 			p.selectedOption = (p.selectedOption + 1) % 3
-			return p, nil
 		case key.Matches(msg, p.keyMap.Left):
 			p.selectedOption = (p.selectedOption + 2) % 3
 		case key.Matches(msg, p.keyMap.Select):
-			return p, p.selectCurrentOption()
+			cmds = append(cmds, p.selectCurrentOption())
 		case key.Matches(msg, p.keyMap.Allow):
-			return p, tea.Batch(
+			cmds = append(cmds, tea.Batch(
 				util.CmdHandler(dialogs.CloseDialogMsg{}),
 				util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}),
-			)
+			))
 		case key.Matches(msg, p.keyMap.AllowSession):
-			return p, tea.Batch(
+			cmds = append(cmds, tea.Batch(
 				util.CmdHandler(dialogs.CloseDialogMsg{}),
 				util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}),
-			)
+			))
 		case key.Matches(msg, p.keyMap.Deny):
-			return p, tea.Batch(
+			cmds = append(cmds, tea.Batch(
 				util.CmdHandler(dialogs.CloseDialogMsg{}),
 				util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
-			)
+			))
 		case key.Matches(msg, p.keyMap.ToggleDiffMode):
 			if p.supportsDiffView() {
 				if p.diffSplitMode == nil {
@@ -138,27 +149,22 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 					*p.diffSplitMode = !*p.diffSplitMode
 				}
 				p.contentDirty = true // Mark content as dirty when diff mode changes
-				return p, nil
 			}
 		case key.Matches(msg, p.keyMap.ScrollDown):
 			if p.supportsDiffView() {
 				p.scrollDown()
-				return p, nil
 			}
 		case key.Matches(msg, p.keyMap.ScrollUp):
 			if p.supportsDiffView() {
 				p.scrollUp()
-				return p, nil
 			}
 		case key.Matches(msg, p.keyMap.ScrollLeft):
 			if p.supportsDiffView() {
 				p.scrollLeft()
-				return p, nil
 			}
 		case key.Matches(msg, p.keyMap.ScrollRight):
 			if p.supportsDiffView() {
 				p.scrollRight()
-				return p, nil
 			}
 		default:
 			// Pass other keys to viewport
@@ -168,6 +174,7 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 	case tea.MouseWheelMsg:
 		if p.supportsDiffView() && p.isMouseOverDialog(msg.Mouse().X, msg.Mouse().Y) {
+			interactionCmd = p.emitInteraction()
 			switch msg.Button {
 			case tea.MouseWheelDown:
 				p.scrollDown()
@@ -181,6 +188,10 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 	}
 
+	if interactionCmd != nil {
+		cmds = append(cmds, interactionCmd)
+	}
+
 	return p, tea.Batch(cmds...)
 }
 
@@ -204,6 +215,16 @@ func (p *permissionDialogCmp) scrollRight() {
 	p.contentDirty = true
 }
 
+func (p *permissionDialogCmp) emitInteraction() tea.Cmd {
+	if p.hasInteracted {
+		return nil
+	}
+	p.hasInteracted = true
+	return util.CmdHandler(PermissionInteractionMsg{
+		ToolCallID: p.permission.ToolCallID,
+	})
+}
+
 // isMouseOverDialog checks if the given mouse coordinates are within the dialog bounds.
 // Returns true if the mouse is over the dialog area, false otherwise.
 func (p *permissionDialogCmp) isMouseOverDialog(x, y int) bool {

internal/tui/page/chat/chat.go 🔗

@@ -174,6 +174,10 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			msg.Y -= 1
 		}
 		if p.isMouseOverChat(msg.X, msg.Y) {
+			// Interaction: cancel any pending turn-end notification for this session.
+			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+				p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+			}
 			u, cmd := p.chat.Update(msg)
 			p.chat = u.(chat.MessageListCmp)
 			return p, cmd
@@ -246,6 +250,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		cmds = append(cmds, cmd)
 		return p, tea.Batch(cmds...)
 	case commands.ToggleCompactModeMsg:
+		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+			p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+		}
 		p.forceCompact = !p.forceCompact
 		var cmd tea.Cmd
 		if p.forceCompact {
@@ -257,8 +264,14 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
 	case commands.ToggleThinkingMsg:
+		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+			p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+		}
 		return p, p.toggleThinking()
 	case commands.OpenReasoningDialogMsg:
+		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+			p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+		}
 		return p, p.openReasoningDialog()
 	case reasoning.ReasoningEffortSelectedMsg:
 		return p, p.handleReasoningEffortSelected(msg.Effort)
@@ -316,6 +329,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		return p, tea.Batch(cmds...)
 	case commands.ToggleYoloModeMsg:
 		// update the editor style
+		if p.app != nil && p.app.AgentCoordinator != nil && p.session.ID != "" && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+			p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+		}
 		u, cmd := p.editor.Update(msg)
 		p.editor = u.(editor.Editor)
 		return p, cmd
@@ -360,6 +376,11 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 		return p, p.newSession()
 	case tea.KeyPressMsg:
+		// If the chat pane is focused, any key-based navigation in chat counts
+		// as interaction; cancel any pending turn-end notification.
+		if p.focusedPane == PanelTypeChat && p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+			p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+		}
 		switch {
 		case key.Matches(msg, p.keyMap.NewSession):
 			// if we have no agent do nothing
@@ -374,6 +395,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			agentCfg := config.Get().Agents[config.AgentCoder]
 			model := config.Get().GetModelByType(agentCfg.Model)
 			if model.SupportsImages {
+				if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+					p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+				}
 				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
 			} else {
 				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
@@ -384,6 +408,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				p.splash = u.(splash.Splash)
 				return p, cmd
 			}
+			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+				p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+			}
 			p.changeFocus()
 			return p, nil
 		case key.Matches(msg, p.keyMap.Cancel):
@@ -391,6 +418,10 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				return p, p.cancel()
 			}
 		case key.Matches(msg, p.keyMap.Details):
+			// Opening/closing the sidebar counts as interaction; cancel pending notification.
+			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+				p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+			}
 			p.toggleDetails()
 			return p, nil
 		}

internal/tui/tui.go 🔗

@@ -237,6 +237,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 	case commands.SwitchModelMsg:
+		// Opening model dialog is interaction; cancel pending turn-end notif.
+		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) {
+			a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID)
+		}
 		return a, util.CmdHandler(
 			dialogs.OpenDialogMsg{
 				Model: models.NewModelDialogCmp(),
@@ -319,6 +323,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			a.app.Permissions.Deny(msg.Permission)
 		}
 		return a, nil
+	case permissions.PermissionInteractionMsg:
+		// Notify that the user interacted with the permission dialog.
+		a.app.Permissions.NotifyInteraction(msg.ToolCallID)
+		return a, nil
 	case splash.OnboardingCompleteMsg:
 		item, ok := a.pages[a.currentPage]
 		if !ok {
@@ -485,6 +493,11 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		return a.handleWindowResize(a.wWidth, a.wHeight)
 	// dialogs
 	case key.Matches(msg, a.keyMap.Commands):
+		// Opening the command palette counts as interaction; cancel pending
+		// turn-end notification for the selected session if any.
+		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) {
+			a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID)
+		}
 		// if the app is not configured show no commands
 		if !a.isConfigured {
 			return nil
@@ -517,6 +530,10 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		if !a.isConfigured {
 			return nil
 		}
+		// Opening sessions dialog is interaction; cancel pending turn-end notif.
+		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) {
+			a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID)
+		}
 		if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
 			return util.CmdHandler(dialogs.CloseDialogMsg{})
 		}

schema.json 🔗

@@ -426,6 +426,11 @@
             "CLAUDE.md",
             "docs/LLMs.md"
           ]
+        },
+        "disable_notifications": {
+          "type": "boolean",
+          "description": "Disable desktop notifications",
+          "default": false
         }
       },
       "additionalProperties": false,