diff --git a/README.md b/README.md index 4135ddea8c6209f05989e64ddc356a2244efbb03..30b4e8bc5198cd96f6fed4380831d6a78a305b92 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/go.mod b/go.mod index 8877336bd0a42158b2a96b3c34c6544d190f2151..91e33fb2986b6290f19eb84ae397e6df6829e0dd 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 29ac00482dedaeb5f7444810b7194a367c6f67a9..e20b1a1ab693d10fbe81256242165991803d5fcf 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 759a9274f2f4cc8c306ac0cc042de89cd1a25097..30c3392939b63aa441551fd0437a5c69789d7b58 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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 { diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go index 29566b1c5a00d00c1254a3f07cdcef71ba55d59e..89b3e7f7d6a52f8bd578beeb90f99034ac12ee7e 100644 --- a/internal/agent/agent_tool.go +++ b/internal/agent/agent_tool.go @@ -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 diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 333ec7926f80735c3798c524378964a8e41fe3e4..04ed3a828c729624661bdbec76dfd63e138a3e27 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 901562420e61fe3950886bebba7ff094eb8c91b6..b8c720c266e6e41f97e47fb5ec58050d88306081 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 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..f8b957a6800e6982557de5214e6d2df6ccd7224c --- /dev/null +++ b/internal/notification/notification_test.go @@ -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) +} diff --git a/internal/permission/permission.go b/internal/permission/permission.go index 829dd2ed90abf4d45b63481eacebb492cadabdfd..c6f6f150431374675c7407f15e26d103b576ed4f 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -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) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e91fae5592b8d51963e524d0662d868cbfed6869..27253a39fed5a8a4aa71a48ed4f8fad6c16b15f2 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -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). diff --git a/schema.json b/schema.json index a2d88bfd5f4be210534209694a9f0c0eb5c993c0..45c8c365e87332e406b146d43c593504d6fa8dc7 100644 --- a/schema.json +++ b/schema.json @@ -440,6 +440,11 @@ "CLAUDE.md", "docs/LLMs.md" ] + }, + "disable_notifications": { + "type": "boolean", + "description": "Disable desktop notifications", + "default": false } }, "additionalProperties": false,