diff --git a/go.mod b/go.mod index f8c69810e844780a76445b55036098c63757f71e..8b0c1985fff68acbc30fdab81db9ac9f9c45b644 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 1458d7a78717609e015f21ea9d52b3b45a6df4dc..ec8e97d5390f096c2445ae18ff086997942a683a 100644 --- a/go.sum +++ b/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= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index ec5bc19ba4efaf0cc15f46620711621a92dff2b9..8c12fd57e1cd3884fad3719894c180f39186e5d0 100644 --- a/internal/agent/agent.go +++ b/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 { diff --git a/internal/config/config.go b/internal/config/config.go index 4b441b0b18563814ca58b9d48cf2e8ffbd7e782f..7c1f413acfb070e7466704433981e19cd4ec35d1 100644 --- a/internal/config/config.go +++ b/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 diff --git a/internal/notification/crush-icon-solo.png b/internal/notification/crush-icon-solo.png new file mode 100644 index 0000000000000000000000000000000000000000..eed026660d0d5882c9b6e98912ee2afd9748f2a6 Binary files /dev/null and b/internal/notification/crush-icon-solo.png differ diff --git a/internal/notification/crush-icon.png b/internal/notification/crush-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..138a7ab6d246a0989cf9fca296e7c7df78523465 Binary files /dev/null and b/internal/notification/crush-icon.png differ diff --git a/internal/notification/export_test.go b/internal/notification/export_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1ddf9b69e88abc88b19416629cb9ba938580b4c8 --- /dev/null +++ b/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 +} diff --git a/internal/notification/notification.go b/internal/notification/notification.go new file mode 100644 index 0000000000000000000000000000000000000000..06b217d606608960e1a8a39ed148900ebff65545 --- /dev/null +++ b/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 +} diff --git a/internal/notification/notification_darwin.go b/internal/notification/notification_darwin.go new file mode 100644 index 0000000000000000000000000000000000000000..dcfa44e0b6cd943c5aee7d13c9f9c3f78c71b8fb --- /dev/null +++ b/internal/notification/notification_darwin.go @@ -0,0 +1,6 @@ +//go:build darwin + +package notification + +// notificationIcon is empty on darwin because icon support is broken. +var notificationIcon interface{} = "" diff --git a/internal/notification/notification_other.go b/internal/notification/notification_other.go new file mode 100644 index 0000000000000000000000000000000000000000..a2a1b39268ab62caccd8c8842d714bc43c5afc11 --- /dev/null +++ b/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 diff --git a/internal/notification/notification_test.go b/internal/notification/notification_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6554ade52fc02cf3b7c100f851b1bb52819db30d --- /dev/null +++ b/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) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c53220094ef6af67f789b98ff177a09fa3dfd7b1..44b6dfbaf5a1693a061adaf5afec4ff3d3911d05 100644 --- a/internal/tui/tui.go +++ b/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( diff --git a/schema.json b/schema.json index 5ba0a700f59e75f89ce147316057930edfe5a453..6cee545839c44069b4b3e137a1259919a473902d 100644 --- a/schema.json +++ b/schema.json @@ -415,6 +415,11 @@ "CLAUDE.md", "docs/LLMs.md" ] + }, + "disable_notifications": { + "type": "boolean", + "description": "Disable desktop notifications", + "default": false } }, "additionalProperties": false,