feat(notification): add em'

Amolith created

Assisted-by: Claude Sonnet 4.5 via Crush

Change summary

go.mod                                       |   9 +
go.sum                                       |  24 +++
internal/agent/agent.go                      |   5 
internal/config/config.go                    |   1 
internal/notification/crush-icon-solo.png    |   0 
internal/notification/crush-icon.png         |   0 
internal/notification/export_test.go         |  13 +
internal/notification/notification.go        |  77 +++++++++++
internal/notification/notification_darwin.go |   6 
internal/notification/notification_other.go  |  13 +
internal/notification/notification_test.go   | 154 ++++++++++++++++++++++
internal/tui/tui.go                          |  21 +++
schema.json                                  |   5 
13 files changed, 328 insertions(+)

Detailed changes

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"
@@ -485,6 +486,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 	}
 	wg.Wait()
 
+	// Send notification that agent has finished its turn.
+	notifBody := fmt.Sprintf("Agent's turn completed in \"%s\"", currentSession.Title)
+	_ = notification.Send("Crush is waiting...", notifBody)
+
 	if shouldSummarize {
 		a.activeRequests.Del(call.SessionID)
 		if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {

internal/config/config.go 🔗

@@ -203,6 +203,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/export_test.go 🔗

@@ -0,0 +1,13 @@
+package notification
+
+import "github.com/gen2brain/beeep"
+
+// SetNotifyFunc allows replacing the notification function for testing.
+func SetNotifyFunc(fn func(string, string, any) error) {
+	notifyFunc = fn
+}
+
+// ResetNotifyFunc resets the notification function to the default.
+func ResetNotifyFunc() {
+	notifyFunc = beeep.Notify
+}

internal/notification/notification.go 🔗

@@ -0,0 +1,77 @@
+package notification
+
+import (
+	"log/slog"
+	"sync"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/gen2brain/beeep"
+)
+
+var (
+	isFocused      = true
+	supportsFocus  = false
+	focusStateLock sync.RWMutex
+
+	// notifyFunc is the function used to send notifications.
+	// It can be swapped for testing.
+	notifyFunc = beeep.Notify
+)
+
+// SetFocusSupport sets whether the terminal supports focus reporting.
+func SetFocusSupport(supported bool) {
+	focusStateLock.Lock()
+	defer focusStateLock.Unlock()
+	supportsFocus = supported
+}
+
+// SetFocused sets whether the terminal window is currently focused.
+func SetFocused(focused bool) {
+	focusStateLock.Lock()
+	defer focusStateLock.Unlock()
+	isFocused = focused
+}
+
+// IsFocused returns whether the terminal window is currently focused.
+func IsFocused() bool {
+	focusStateLock.RLock()
+	defer focusStateLock.RUnlock()
+	return isFocused
+}
+
+// Send sends a desktop notification with the given title and message.
+// Notifications are only sent when focus reporting is supported, the terminal window is not focused, and notifications are not disabled in config.
+// On darwin (macOS), icons are omitted due to platform limitations.
+func Send(title, message string) error {
+	// Check if notifications are disabled in config
+	cfg := config.Get()
+	if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
+		slog.Debug("skipping notification: disabled in config")
+		return nil
+	}
+
+	focusStateLock.RLock()
+	focused := isFocused
+	supported := supportsFocus
+	focusStateLock.RUnlock()
+
+	slog.Debug("notification.Send called", "title", title, "message", message, "focused", focused, "supported", supported)
+
+	// Only send notifications if focus reporting is supported and window is not focused.
+	if !supported || focused {
+		slog.Debug("skipping notification: focus not supported or window is focused")
+		return nil
+	}
+
+	beeep.AppName = "Crush"
+
+	err := notifyFunc(title, message, notificationIcon)
+
+	if err != nil {
+		slog.Error("failed to send notification", "error", err)
+	} else {
+		slog.Debug("notification sent successfully")
+	}
+
+	return err
+}

internal/notification/notification_other.go 🔗

@@ -0,0 +1,13 @@
+//go:build !darwin
+
+package notification
+
+import (
+	_ "embed"
+)
+
+//go:embed crush-icon-solo.png
+var icon []byte
+
+// notificationIcon contains the embedded PNG icon data for desktop notifications.
+var notificationIcon interface{} = icon

internal/notification/notification_test.go 🔗

@@ -0,0 +1,154 @@
+package notification_test
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/notification"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSend_Disabled(t *testing.T) {
+	// Setup a temporary config with DisableNotifications = true
+	tempDir := t.TempDir()
+	cfg, err := config.Init(tempDir, tempDir, false)
+	require.NoError(t, err)
+
+	// Explicitly disable notifications
+	cfg.Options.DisableNotifications = true
+
+	// Call Send
+	// This should return nil immediately because notifications are disabled
+	err = notification.Send("Test Title", "Test Message")
+	require.NoError(t, err)
+}
+
+func TestSend_Focused(t *testing.T) {
+	// Reset globals after test
+	defer func() {
+		notification.SetFocusSupport(false)
+		notification.SetFocused(true)
+	}()
+
+	// Setup a temporary config with DisableNotifications = false
+	tempDir := t.TempDir()
+	cfg, err := config.Init(tempDir, tempDir, false)
+	require.NoError(t, err)
+
+	cfg.Options.DisableNotifications = false
+
+	// Set up focus state so notification should be skipped
+	notification.SetFocusSupport(true)
+	notification.SetFocused(true)
+
+	// Call Send
+	// This should return nil immediately because window is focused
+	err = notification.Send("Test Title", "Test Message")
+	require.NoError(t, err)
+}
+
+func TestSend_Success(t *testing.T) {
+	// Reset globals after test
+	defer func() {
+		notification.SetFocusSupport(false)
+		notification.SetFocused(true)
+		notification.ResetNotifyFunc()
+	}()
+
+	// Setup a temporary config with DisableNotifications = false
+	tempDir := t.TempDir()
+	cfg, err := config.Init(tempDir, tempDir, false)
+	require.NoError(t, err)
+
+	cfg.Options.DisableNotifications = false
+
+	// Set up focus state so notification should NOT be skipped
+	notification.SetFocusSupport(true)
+	notification.SetFocused(false)
+
+	// Mock the notify function
+	var capturedTitle, capturedMessage string
+	var capturedIcon any
+	mockNotify := func(title, message string, icon any) error {
+		capturedTitle = title
+		capturedMessage = message
+		capturedIcon = icon
+		return nil
+	}
+	notification.SetNotifyFunc(mockNotify)
+
+	// Call Send
+	err = notification.Send("Hello", "World")
+	require.NoError(t, err)
+
+	// Verify mock was called with correct arguments
+	require.Equal(t, "Hello", capturedTitle)
+	require.Equal(t, "World", capturedMessage)
+	require.NotNil(t, capturedIcon)
+}
+
+func TestSend_FocusNotSupported(t *testing.T) {
+	// Reset globals after test
+	defer func() {
+		notification.SetFocusSupport(false)
+		notification.SetFocused(true)
+		notification.ResetNotifyFunc()
+	}()
+
+	// Setup a temporary config with DisableNotifications = false
+	tempDir := t.TempDir()
+	cfg, err := config.Init(tempDir, tempDir, false)
+	require.NoError(t, err)
+
+	cfg.Options.DisableNotifications = false
+
+	// Focus support disabled, but "focused" is true (simulate default state where we assume focused but can't verify, or just focus tracking disabled)
+	// The logic says: "Do NOT send if focus reporting is not supported."
+	notification.SetFocusSupport(false)
+	notification.SetFocused(true)
+
+	// Mock the notify function
+	called := false
+	mockNotify := func(title, message string, icon any) error {
+		called = true
+		return nil
+	}
+	notification.SetNotifyFunc(mockNotify)
+
+	// Call Send
+	err = notification.Send("Title", "Message")
+	require.NoError(t, err)
+	require.False(t, called, "Should NOT send notification if focus support is disabled")
+}
+
+func TestSend_Error(t *testing.T) {
+	// Reset globals after test
+	defer func() {
+		notification.SetFocusSupport(false)
+		notification.SetFocused(true)
+		notification.ResetNotifyFunc()
+	}()
+
+	// Setup a temporary config with DisableNotifications = false
+	tempDir := t.TempDir()
+	cfg, err := config.Init(tempDir, tempDir, false)
+	require.NoError(t, err)
+
+	cfg.Options.DisableNotifications = false
+
+	// Ensure we try to send
+	notification.SetFocusSupport(true)
+	notification.SetFocused(false)
+
+	// Mock error
+	expectedErr := fmt.Errorf("mock error")
+	mockNotify := func(title, message string, icon any) error {
+		return expectedErr
+	}
+	notification.SetNotifyFunc(mockNotify)
+
+	// Call Send
+	err = notification.Send("Title", "Message")
+	require.Equal(t, expectedErr, err)
+}

internal/tui/tui.go 🔗

@@ -15,6 +15,7 @@ import (
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/event"
+	"github.com/charmbracelet/crush/internal/notification"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
@@ -34,6 +35,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/page/chat"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/x/ansi"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 )
@@ -104,6 +106,9 @@ func (a appModel) Init() tea.Cmd {
 		cmds = append(cmds, tea.RequestTerminalVersion)
 	}
 
+	// Request focus event support from the terminal.
+	cmds = append(cmds, tea.Raw(ansi.RequestModeFocusEvent))
+
 	return tea.Batch(cmds...)
 }
 
@@ -114,6 +119,18 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	a.isConfigured = config.HasInitialDataConfig()
 
 	switch msg := msg.(type) {
+	case tea.ModeReportMsg:
+		if msg.Mode == ansi.ModeFocusEvent && !msg.Value.IsNotRecognized() {
+			notification.SetFocusSupport(true)
+			notification.SetFocused(true)
+		}
+		return a, nil
+	case tea.FocusMsg:
+		notification.SetFocused(true)
+		return a, nil
+	case tea.BlurMsg:
+		notification.SetFocused(false)
+		return a, nil
 	case tea.EnvMsg:
 		// Is this Windows Terminal?
 		if !a.sendProgressBar {
@@ -304,6 +321,9 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		return a, itemCmd
 	case pubsub.Event[permission.PermissionRequest]:
+		// Send notification if window is not focused.
+		notifBody := fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName)
+		_ = notification.Send("Crush is waiting...", notifBody)
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
 			Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
 				DiffMode: config.Get().Options.TUI.DiffMode,
@@ -580,6 +600,7 @@ func (a *appModel) View() tea.View {
 	view.AltScreen = true
 	view.MouseMode = tea.MouseModeCellMotion
 	view.BackgroundColor = t.BgBase
+	view.ReportFocus = true
 	if a.wWidth < 25 || a.wHeight < 15 {
 		view.SetContent(
 			lipgloss.NewCanvas(

schema.json 🔗

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