Detailed changes
@@ -408,6 +408,25 @@ git clone https://github.com/anthropics/skills.git _temp
mv _temp/skills/* . ; rm -r -force _temp
```
+### Desktop notifications
+
+Crush sends desktop notifications when a tool call requires permission and when
+the agent finishes its turn. They're only sent when the terminal window isn't
+focused _and_ your terminal supports reporting the focus state.
+
+```jsonc
+{
+ "$schema": "https://charm.land/crush.json",
+ "options": {
+ "disable_notifications": false // default
+ }
+}
+```
+
+To disable desktop notifications, set `disable_notifications` to `true` in your
+configuration. On macOS, notifications currently lack icons due to platform
+limitations.
+
### Initialization
When you initialize a project, Crush analyzes your codebase and creates
@@ -32,6 +32,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.2
github.com/google/uuid v1.6.0
github.com/invopop/jsonschema v0.13.0
github.com/joho/godotenv v1.5.1
@@ -71,6 +72,7 @@ require (
cloud.google.com/go/auth v0.18.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
+ git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/RealAlexandreAI/json-repair v0.0.14 // indirect
@@ -105,14 +107,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.19.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
@@ -122,6 +127,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.2 // indirect
github.com/kaptinlin/jsonpointer v0.4.8 // indirect
github.com/kaptinlin/jsonschema v0.6.5 // indirect
@@ -143,10 +149,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.11.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
@@ -20,6 +20,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
+git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=
+git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
@@ -153,11 +155,15 @@ github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8k
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
+github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=
+github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA=
+github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc=
github.com/go-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=
@@ -167,12 +173,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.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.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=
@@ -203,6 +213,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=
@@ -299,6 +311,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=
@@ -316,11 +332,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.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -35,6 +35,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"
@@ -62,6 +63,7 @@ type SessionAgentCall struct {
TopK *int64
FrequencyPenalty *float64
PresencePenalty *float64
+ NonInteractive bool
}
type SessionAgent interface {
@@ -492,6 +494,12 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
}
wg.Wait()
+ // Send notification that agent has finished its turn (skip for nested/non-interactive sessions).
+ if !call.NonInteractive {
+ 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 {
@@ -81,6 +81,7 @@ func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error)
TopK: model.ModelCfg.TopK,
FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
PresencePenalty: model.ModelCfg.PresencePenalty,
+ NonInteractive: true,
})
if err != nil {
return fantasy.NewTextErrorResponse("error generating response"), nil
@@ -205,6 +205,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
TopK: small.ModelCfg.TopK,
FrequencyPenalty: small.ModelCfg.FrequencyPenalty,
PresencePenalty: small.ModelCfg.PresencePenalty,
+ NonInteractive: true,
})
if err != nil {
return fantasy.NewTextErrorResponse("error generating response"), nil
@@ -251,6 +251,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
@@ -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
+}
@@ -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
+}
@@ -0,0 +1,6 @@
+//go:build darwin
+
+package notification
+
+// notificationIcon is empty on darwin because icon support is broken.
+var notificationIcon interface{} = ""
@@ -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
@@ -0,0 +1,171 @@
+package notification_test
+
+import (
+ "fmt"
+ "os"
+ "testing"
+
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/log"
+ "github.com/charmbracelet/crush/internal/notification"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSend_Disabled(t *testing.T) {
+ // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
+ log.Setup(os.DevNull, false)
+
+ // 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) {
+ // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
+ log.Setup(os.DevNull, false)
+
+ // 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) {
+ // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
+ log.Setup(os.DevNull, false)
+
+ // 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) {
+ // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
+ log.Setup(os.DevNull, false)
+
+ // 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) {
+ // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
+ log.Setup(os.DevNull, false)
+
+ // 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)
+}
@@ -127,6 +127,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 +140,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,15 +181,6 @@ 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)
@@ -16,6 +16,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"
"github.com/charmbracelet/crush/internal/stringext"
@@ -36,6 +37,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/mod/semver"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@@ -107,6 +109,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...)
}
@@ -117,6 +122,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 {
@@ -316,6 +333,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,
@@ -592,6 +612,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.Content = t.S().Base.Width(a.wWidth).Height(a.wHeight).
Align(lipgloss.Center, lipgloss.Center).
@@ -440,6 +440,11 @@
"CLAUDE.md",
"docs/LLMs.md"
]
+ },
+ "disable_notifications": {
+ "type": "boolean",
+ "description": "Disable desktop notifications",
+ "default": false
}
},
"additionalProperties": false,