diff --git a/README.md b/README.md index 17ddcecbef8011b15ab1600684e8a8e228d06df7..ee4ec0151c48c4373e0ff6a7f78aec2d83ee027f 100644 --- a/README.md +++ b/README.md @@ -605,6 +605,29 @@ config: } ``` +## Desktop Notifications + +Crush defaults to sending desktop notifications that let you know: + +- When its turn is finished (automatically cancelled when you interact with the + chat interface) +- When it's waiting for permission to execute a tool (automatically cancelled + when you interact with the permission dialog) + +### Disabling notifications + +If you prefer to work without desktop notifications, you can disable them in +your `crush.json` config. + +```json +{ + "$schema": "https://charm.land/crush.json", + "options": { + "disable_notifications": true + } +} +``` + ## Provider Auto-Updates By default, Crush automatically checks for the latest and greatest list of 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..57b1d826315eb57a1ebbd83b871eb6cc4cf60542 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" @@ -67,8 +68,16 @@ type SessionAgent interface { ClearQueue(sessionID string) Summarize(context.Context, string, fantasy.ProviderOptions) error Model() Model + // CancelCompletionNotification cancels any scheduled "turn ended" + // notification for the provided sessionID. + CancelCompletionNotification(sessionID string) + // HasPendingCompletionNotification returns true if a turn-end + // notification has been scheduled and not yet cancelled/shown. + HasPendingCompletionNotification(sessionID string) bool } +const completionNotificationDelay = 5 * time.Second + type Model struct { Model fantasy.LanguageModel CatwalkCfg catwalk.Model @@ -86,8 +95,11 @@ type sessionAgent struct { disableAutoSummarize bool isYolo bool - messageQueue *csync.Map[string, []SessionAgentCall] - activeRequests *csync.Map[string, context.CancelFunc] + messageQueue *csync.Map[string, []SessionAgentCall] + activeRequests *csync.Map[string, context.CancelFunc] + notifier *notification.Notifier + notifyCtx context.Context + completionCancels *csync.Map[string, context.CancelFunc] } type SessionAgentOptions struct { @@ -100,11 +112,18 @@ type SessionAgentOptions struct { Sessions session.Service Messages message.Service Tools []fantasy.AgentTool + Notifier *notification.Notifier + NotificationCtx context.Context } func NewSessionAgent( opts SessionAgentOptions, ) SessionAgent { + notifyCtx := opts.NotificationCtx + if notifyCtx == nil { + notifyCtx = context.Background() + } + return &sessionAgent{ largeModel: opts.LargeModel, smallModel: opts.SmallModel, @@ -117,6 +136,9 @@ func NewSessionAgent( isYolo: opts.IsYolo, messageQueue: csync.NewMap[string, []SessionAgentCall](), activeRequests: csync.NewMap[string, context.CancelFunc](), + notifier: opts.Notifier, + notifyCtx: notifyCtx, + completionCancels: csync.NewMap[string, context.CancelFunc](), } } @@ -128,6 +150,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy return nil, ErrSessionMissing } + a.cancelCompletionNotification(call.SessionID) + // Queue the message if busy if a.IsSessionBusy(call.SessionID) { existing, ok := a.messageQueue.Get(call.SessionID) @@ -373,6 +397,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy if sessionErr != nil { return sessionErr } + if finishReason == message.FinishReasonEndTurn { + a.scheduleCompletionNotification(call.SessionID, currentSession.Title) + } return a.messages.Update(genCtx, *currentAssistant) }, StopWhen: []fantasy.StopCondition{ @@ -486,6 +513,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy wg.Wait() if shouldSummarize { + a.cancelCompletionNotification(call.SessionID) a.activeRequests.Del(call.SessionID) if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil { return nil, summarizeErr @@ -516,6 +544,56 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy return a.Run(ctx, firstQueuedMessage) } +func (a *sessionAgent) scheduleCompletionNotification(sessionID, sessionTitle string) { + // Do not emit notifications for Agent-tool sub-sessions. + if a.sessions != nil && a.sessions.IsAgentToolSession(sessionID) { + return + } + if a.notifier == nil { + return + } + + if sessionTitle == "" { + sessionTitle = sessionID + } + + if cancel, ok := a.completionCancels.Take(sessionID); ok && cancel != nil { + cancel() + } + + title := "💘 Crush is waiting" + message := fmt.Sprintf("Agent's turn completed in session \"%s\"", sessionTitle) + cancel := a.notifier.NotifyTaskComplete(a.notifyCtx, title, message, completionNotificationDelay) + if cancel == nil { + cancel = func() {} + } + a.completionCancels.Set(sessionID, cancel) +} + +func (a *sessionAgent) cancelCompletionNotification(sessionID string) { + if a.notifier == nil { + return + } + + if cancel, ok := a.completionCancels.Take(sessionID); ok && cancel != nil { + cancel() + } +} + +// CancelCompletionNotification implements SessionAgent. +func (a *sessionAgent) CancelCompletionNotification(sessionID string) { + a.cancelCompletionNotification(sessionID) +} + +// HasPendingCompletionNotification implements SessionAgent. +func (a *sessionAgent) HasPendingCompletionNotification(sessionID string) bool { + if a.IsSessionBusy(sessionID) { + return false + } + _, ok := a.completionCancels.Get(sessionID) + return ok +} + func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fantasy.ProviderOptions) error { if a.IsSessionBusy(sessionID) { return ErrSessionBusy @@ -829,6 +907,13 @@ func (a *sessionAgent) ClearQueue(sessionID string) { func (a *sessionAgent) CancelAll() { if !a.IsBusy() { + // still ensure notifications are cancelled even when not busy + for cancel := range a.completionCancels.Seq() { + if cancel != nil { + cancel() + } + } + a.completionCancels.Reset(make(map[string]context.CancelFunc)) return } for key := range a.activeRequests.Seq2() { @@ -844,6 +929,13 @@ func (a *sessionAgent) CancelAll() { time.Sleep(200 * time.Millisecond) } } + + for cancel := range a.completionCancels.Seq() { + if cancel != nil { + cancel() + } + } + a.completionCancels.Reset(make(map[string]context.CancelFunc)) } func (a *sessionAgent) IsBusy() bool { diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 58264e2637f3a44e45d54a66c72ee3b8d6c642a3..40ab94e05678a0a784aa9c4d47d57a14d04d3d80 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -115,7 +115,7 @@ func testEnv(t *testing.T) fakeEnv { sessions := session.NewService(q) messages := message.NewService(q) - permissions := permission.NewPermissionService(workingDir, true, []string{}) + permissions := permission.NewPermissionService(t.Context(), workingDir, true, []string{}, nil) history := history.NewService(q, conn) lspClients := csync.NewMap[string, *lsp.Client]() @@ -149,7 +149,19 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro DefaultMaxTokens: 10000, }, } - agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, true, env.sessions, env.messages, tools}) + agent := NewSessionAgent(SessionAgentOptions{ + LargeModel: largeModel, + SmallModel: smallModel, + SystemPromptPrefix: "", + SystemPrompt: systemPrompt, + DisableAutoSummarize: false, + IsYolo: true, + Sessions: env.sessions, + Messages: env.messages, + Tools: tools, + Notifier: nil, + NotificationCtx: context.Background(), + }) return agent } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 4bfcc0062ae9a06dc858989f2cce925976d6d32b..0b28c0d2d58f81caca4e5d58711311d6ed4866d6 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -24,6 +24,7 @@ import ( "github.com/charmbracelet/crush/internal/log" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/notification" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" "golang.org/x/sync/errgroup" @@ -52,6 +53,12 @@ type Coordinator interface { Summarize(context.Context, string) error Model() Model UpdateModels(ctx context.Context) error + // CancelCompletionNotification cancels any scheduled "turn ended" + // notification for the provided sessionID. + CancelCompletionNotification(sessionID string) + // HasPendingCompletionNotification reports whether there is a pending + // turn-end notification for the given session. + HasPendingCompletionNotification(sessionID string) bool } type coordinator struct { @@ -61,6 +68,8 @@ type coordinator struct { permissions permission.Service history history.Service lspClients *csync.Map[string, *lsp.Client] + notifier *notification.Notifier + notifyCtx context.Context currentAgent SessionAgent agents map[string]SessionAgent @@ -76,7 +85,11 @@ func NewCoordinator( permissions permission.Service, history history.Service, lspClients *csync.Map[string, *lsp.Client], + notifier *notification.Notifier, ) (Coordinator, error) { + if ctx == nil { + ctx = context.Background() + } c := &coordinator{ cfg: cfg, sessions: sessions, @@ -84,6 +97,8 @@ func NewCoordinator( permissions: permissions, history: history, lspClients: lspClients, + notifier: notifier, + notifyCtx: ctx, agents: make(map[string]SessionAgent), } @@ -144,6 +159,22 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, }) } +// CancelCompletionNotification implements Coordinator. +func (c *coordinator) CancelCompletionNotification(sessionID string) { + if c.currentAgent == nil || sessionID == "" { + return + } + c.currentAgent.CancelCompletionNotification(sessionID) +} + +// HasPendingCompletionNotification implements Coordinator. +func (c *coordinator) HasPendingCompletionNotification(sessionID string) bool { + if c.currentAgent == nil || sessionID == "" { + return false + } + return c.currentAgent.HasPendingCompletionNotification(sessionID) +} + func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions { options := fantasy.ProviderOptions{} @@ -287,15 +318,17 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider) result := NewSessionAgent(SessionAgentOptions{ - large, - small, - largeProviderCfg.SystemPromptPrefix, - systemPrompt, - c.cfg.Options.DisableAutoSummarize, - c.permissions.SkipRequests(), - c.sessions, - c.messages, - nil, + LargeModel: large, + SmallModel: small, + SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix, + SystemPrompt: systemPrompt, + DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize, + IsYolo: c.permissions.SkipRequests(), + Sessions: c.sessions, + Messages: c.messages, + Tools: nil, + Notifier: c.notifier, + NotificationCtx: c.notifyCtx, }) c.readyWg.Go(func() error { tools, err := c.buildTools(ctx, agent) diff --git a/internal/agent/tools/multiedit_test.go b/internal/agent/tools/multiedit_test.go index 98cf6139f24f6ae323bc20736c5675003202f96a..23bea91e9e568e8e284f1ce7feb5bbc009495bca 100644 --- a/internal/agent/tools/multiedit_test.go +++ b/internal/agent/tools/multiedit_test.go @@ -40,6 +40,8 @@ func (m *mockPermissionService) SubscribeNotifications(ctx context.Context) <-ch return make(<-chan pubsub.Event[permission.PermissionNotification]) } +func (m *mockPermissionService) NotifyInteraction(toolCallID string) {} + type mockHistoryService struct { *pubsub.Broker[history.File] } diff --git a/internal/app/app.go b/internal/app/app.go index 8519f258502ad10f146870b89c7bb5f0f50994e4..c4ff45a97920671af770cfdf7afa52214e341bfb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -26,6 +26,7 @@ import ( "github.com/charmbracelet/crush/internal/log" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/notification" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" @@ -46,6 +47,7 @@ type App struct { Permissions permission.Service AgentCoordinator agent.Coordinator + Notifier *notification.Notifier LSPClients *csync.Map[string, *lsp.Client] @@ -73,11 +75,18 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { allowedTools = cfg.Permissions.AllowedTools } + // Enable notifications by default, disable if configured. + notificationsEnabled := cfg.Options == nil || !cfg.Options.DisableNotifications + + notifier := notification.New(notificationsEnabled) + permissions := permission.NewPermissionService(ctx, cfg.WorkingDir(), skipPermissionsRequests, allowedTools, notifier) + app := &App{ Sessions: sessions, Messages: messages, History: files, - Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools), + Permissions: permissions, + Notifier: notifier, LSPClients: csync.NewMap[string, *lsp.Client](), globalCtx: ctx, @@ -328,6 +337,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error { app.Permissions, app.History, app.LSPClients, + app.Notifier, ) if err != nil { slog.Error("Failed to create coder agent", "err", err) diff --git a/internal/config/config.go b/internal/config/config.go index 858f6717e80b7f1905a3c636d0749345d4ed0527..145c1b9cd32644544c0102809178cbc4029e0339 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -204,6 +204,7 @@ type Options struct { Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"` DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"` InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"` + DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"` } type MCPs map[string]MCPConfig diff --git a/internal/notification/notification.go b/internal/notification/notification.go new file mode 100644 index 0000000000000000000000000000000000000000..304639f8e59497071fa14e258104a68028a08dfd --- /dev/null +++ b/internal/notification/notification.go @@ -0,0 +1,77 @@ +package notification + +import ( + "context" + "log/slog" + "time" + + "github.com/gen2brain/beeep" +) + +// Notifier handles sending native notifications. +type Notifier struct { + enabled bool +} + +// New creates a new Notifier instance. +func New(enabled bool) *Notifier { + return &Notifier{ + enabled: enabled, + } +} + +// NotifyTaskComplete sends a notification when a task is completed. +// It waits for the specified delay before sending the notification. +func (n *Notifier) NotifyTaskComplete(ctx context.Context, title, message string, delay time.Duration) context.CancelFunc { + if !n.enabled { + slog.Debug("Notifications disabled, skipping completion notification") + return func() {} + } + + notifyCtx, cancel := context.WithCancel(ctx) + go func() { + slog.Debug("Waiting before sending completion notification", "delay", delay) + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-timer.C: + slog.Debug("Sending completion notification", "title", title, "message", message) + if err := beeep.Notify(title, message, ""); err != nil { + slog.Warn("Failed to send notification", "error", err, "title", title, "message", message) + } else { + slog.Debug("Notification sent successfully", "title", title) + } + case <-notifyCtx.Done(): + slog.Debug("Completion notification cancelled") + } + }() + return cancel +} + +// NotifyPermissionRequest sends a notification when a permission request needs attention. +// It waits for the specified delay before sending the notification. +func (n *Notifier) NotifyPermissionRequest(ctx context.Context, title, message string, delay time.Duration) context.CancelFunc { + if !n.enabled { + slog.Debug("Notifications disabled, skipping permission request notification") + return func() {} + } + + notifyCtx, cancel := context.WithCancel(ctx) + go func() { + slog.Debug("Waiting before sending permission request notification", "delay", delay) + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-timer.C: + slog.Debug("Sending permission request notification", "title", title, "message", message) + if err := beeep.Notify(title, message, ""); err != nil { + slog.Warn("Failed to send notification", "error", err, "title", title, "message", message) + } else { + slog.Debug("Notification sent successfully", "title", title) + } + case <-notifyCtx.Done(): + slog.Debug("Permission request notification cancelled") + } + }() + return cancel +} diff --git a/internal/notification/notification_test.go b/internal/notification/notification_test.go new file mode 100644 index 0000000000000000000000000000000000000000..46920aa2527baea673bd4d5f13a18824935f7943 --- /dev/null +++ b/internal/notification/notification_test.go @@ -0,0 +1,74 @@ +package notification + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + t.Parallel() + + t.Run("creates notifier with notifications enabled", func(t *testing.T) { + t.Parallel() + n := New(true) + require.NotNil(t, n) + require.True(t, n.enabled) + }) + + t.Run("creates notifier with notifications disabled", func(t *testing.T) { + t.Parallel() + n := New(false) + require.NotNil(t, n) + require.False(t, n.enabled) + }) +} + +func TestNotifyTaskComplete(t *testing.T) { + t.Parallel() + + t.Run("does not panic when notifications enabled", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + n := New(true) + require.NotPanics(t, func() { + cancel := n.NotifyTaskComplete(ctx, "Test Title", "Test Message", 0) + defer cancel() + }) + }) + + t.Run("does not panic when notifications disabled", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + n := New(false) + require.NotPanics(t, func() { + cancel := n.NotifyTaskComplete(ctx, "Test Title", "Test Message", 0) + defer cancel() + }) + }) +} + +func TestNotifyPermissionRequest(t *testing.T) { + t.Parallel() + + t.Run("does not panic when notifications enabled", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + n := New(true) + require.NotPanics(t, func() { + cancel := n.NotifyPermissionRequest(ctx, "Test Title", "Test Message", 0) + defer cancel() + }) + }) + + t.Run("does not panic when notifications disabled", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + n := New(false) + require.NotPanics(t, func() { + cancel := n.NotifyPermissionRequest(ctx, "Test Title", "Test Message", 0) + defer cancel() + }) + }) +} diff --git a/internal/permission/permission.go b/internal/permission/permission.go index e7bc3f65f22fd6a0df396816c77150355e36fca6..9ede4a6504c8f19f7f3b0a4f5a3ada58e781c904 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -3,10 +3,12 @@ package permission import ( "context" "errors" + "fmt" "os" "path/filepath" "slices" "sync" + "time" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/pubsub" @@ -29,6 +31,7 @@ type PermissionNotification struct { ToolCallID string `json:"tool_call_id"` Granted bool `json:"granted"` Denied bool `json:"denied"` + Interacted bool `json:"interacted"` // true when user scrolls or navigates the dialog } type PermissionRequest struct { @@ -52,12 +55,22 @@ type Service interface { SetSkipRequests(skip bool) SkipRequests() bool SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] + NotifyInteraction(toolCallID string) } +type permissionNotifier interface { + NotifyPermissionRequest(ctx context.Context, title, message string, delay time.Duration) context.CancelFunc +} + +const permissionNotificationDelay = 5 * time.Second + type permissionService struct { *pubsub.Broker[PermissionRequest] notificationBroker *pubsub.Broker[PermissionNotification] + notifier permissionNotifier + notificationCtx context.Context + notificationCancels *csync.Map[string, context.CancelFunc] workingDir string sessionPermissions []PermissionRequest sessionPermissionsMu sync.RWMutex @@ -73,6 +86,7 @@ type permissionService struct { } func (s *permissionService) GrantPersistent(permission PermissionRequest) { + s.cancelPermissionNotification(permission.ToolCallID) s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{ ToolCallID: permission.ToolCallID, Granted: true, @@ -92,6 +106,7 @@ func (s *permissionService) GrantPersistent(permission PermissionRequest) { } func (s *permissionService) Grant(permission PermissionRequest) { + s.cancelPermissionNotification(permission.ToolCallID) s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{ ToolCallID: permission.ToolCallID, Granted: true, @@ -107,6 +122,7 @@ func (s *permissionService) Grant(permission PermissionRequest) { } func (s *permissionService) Deny(permission PermissionRequest) { + s.cancelPermissionNotification(permission.ToolCallID) s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{ ToolCallID: permission.ToolCallID, Granted: false, @@ -127,6 +143,12 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool { return true } + // Check if the tool/action combination is in the allowlist + commandKey := opts.ToolName + ":" + opts.Action + if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) { + return true + } + // tell the UI that a permission was requested s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{ ToolCallID: opts.ToolCallID, @@ -134,12 +156,6 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool { s.requestMu.Lock() defer s.requestMu.Unlock() - // Check if the tool/action combination is in the allowlist - commandKey := opts.ToolName + ":" + opts.Action - if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) { - return true - } - s.autoApproveSessionsMu.RLock() autoApprove := s.autoApproveSessions[opts.SessionID] s.autoApproveSessionsMu.RUnlock() @@ -181,21 +197,14 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool { } s.sessionPermissionsMu.RUnlock() - s.sessionPermissionsMu.RLock() - for _, p := range s.sessionPermissions { - if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path { - s.sessionPermissionsMu.RUnlock() - return true - } - } - s.sessionPermissionsMu.RUnlock() - s.activeRequest = &permission respCh := make(chan bool, 1) s.pendingRequests.Set(permission.ID, respCh) defer s.pendingRequests.Del(permission.ID) + s.schedulePermissionNotification(permission) + // Publish the request s.Publish(pubsub.CreatedEvent, permission) @@ -212,6 +221,14 @@ func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan p return s.notificationBroker.Subscribe(ctx) } +func (s *permissionService) NotifyInteraction(toolCallID string) { + s.cancelPermissionNotification(toolCallID) + s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{ + ToolCallID: toolCallID, + Interacted: true, + }) +} + func (s *permissionService) SetSkipRequests(skip bool) { s.skip = skip } @@ -220,10 +237,16 @@ func (s *permissionService) SkipRequests() bool { return s.skip } -func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service { +func NewPermissionService(ctx context.Context, workingDir string, skip bool, allowedTools []string, notifier permissionNotifier) Service { + if ctx == nil { + ctx = context.Background() + } return &permissionService{ Broker: pubsub.NewBroker[PermissionRequest](), notificationBroker: pubsub.NewBroker[PermissionNotification](), + notifier: notifier, + notificationCtx: ctx, + notificationCancels: csync.NewMap[string, context.CancelFunc](), workingDir: workingDir, sessionPermissions: make([]PermissionRequest, 0), autoApproveSessions: make(map[string]bool), @@ -232,3 +255,31 @@ func NewPermissionService(workingDir string, skip bool, allowedTools []string) S pendingRequests: csync.NewMap[string, chan bool](), } } + +func (s *permissionService) schedulePermissionNotification(permission PermissionRequest) { + if s.notifier == nil { + return + } + + if cancel, ok := s.notificationCancels.Take(permission.ToolCallID); ok && cancel != nil { + cancel() + } + + title := "💘 Crush is waiting" + message := fmt.Sprintf("Permission required to execute \"%s\"", permission.ToolName) + cancel := s.notifier.NotifyPermissionRequest(s.notificationCtx, title, message, permissionNotificationDelay) + if cancel == nil { + cancel = func() {} + } + s.notificationCancels.Set(permission.ToolCallID, cancel) +} + +func (s *permissionService) cancelPermissionNotification(toolCallID string) { + if s.notifier == nil { + return + } + + if cancel, ok := s.notificationCancels.Take(toolCallID); ok && cancel != nil { + cancel() + } +} diff --git a/internal/permission/permission_test.go b/internal/permission/permission_test.go index d1ccd286836768f1bc1119966568941f7494affd..387759844416830e691a5142bffd0def5d90c350 100644 --- a/internal/permission/permission_test.go +++ b/internal/permission/permission_test.go @@ -1,10 +1,13 @@ package permission import ( + "context" "sync" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPermissionService_AllowedCommands(t *testing.T) { @@ -54,7 +57,7 @@ func TestPermissionService_AllowedCommands(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - service := NewPermissionService("/tmp", false, tt.allowedTools) + service := NewPermissionService(t.Context(), "/tmp", false, tt.allowedTools, nil) // Create a channel to capture the permission request // Since we're testing the allowlist logic, we need to simulate the request @@ -79,7 +82,7 @@ func TestPermissionService_AllowedCommands(t *testing.T) { } func TestPermissionService_SkipMode(t *testing.T) { - service := NewPermissionService("/tmp", true, []string{}) + service := NewPermissionService(t.Context(), "/tmp", true, []string{}, nil) result := service.Request(CreatePermissionRequest{ SessionID: "test-session", @@ -96,7 +99,7 @@ func TestPermissionService_SkipMode(t *testing.T) { func TestPermissionService_SequentialProperties(t *testing.T) { t.Run("Sequential permission requests with persistent grants", func(t *testing.T) { - service := NewPermissionService("/tmp", false, []string{}) + service := NewPermissionService(t.Context(), "/tmp", false, []string{}, nil) req1 := CreatePermissionRequest{ SessionID: "session1", @@ -140,7 +143,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) { assert.True(t, result2, "Second request should be auto-approved") }) t.Run("Sequential requests with temporary grants", func(t *testing.T) { - service := NewPermissionService("/tmp", false, []string{}) + service := NewPermissionService(t.Context(), "/tmp", false, []string{}, nil) req := CreatePermissionRequest{ SessionID: "session2", @@ -180,12 +183,11 @@ func TestPermissionService_SequentialProperties(t *testing.T) { assert.False(t, result2, "Second request should be denied") }) t.Run("Concurrent requests with different outcomes", func(t *testing.T) { - service := NewPermissionService("/tmp", false, []string{}) + service := NewPermissionService(t.Context(), "/tmp", false, []string{}, nil) events := service.Subscribe(t.Context()) var wg sync.WaitGroup - results := make([]bool, 0) requests := []CreatePermissionRequest{ { @@ -211,11 +213,13 @@ func TestPermissionService_SequentialProperties(t *testing.T) { }, } + results := make([]bool, len(requests)) + for i, req := range requests { wg.Add(1) go func(index int, request CreatePermissionRequest) { defer wg.Done() - results = append(results, service.Request(request)) + results[index] = service.Request(request) }(i, req) } @@ -245,3 +249,113 @@ func TestPermissionService_SequentialProperties(t *testing.T) { assert.True(t, result, "Repeated request should be auto-approved due to persistent permission") }) } + +func TestPermissionService_NotifierSchedulesAndCancelsOnGrant(t *testing.T) { + notifier := &testPermissionNotifier{} + service := NewPermissionService(t.Context(), "/tmp", false, []string{}, notifier) + + events := service.Subscribe(t.Context()) + req := CreatePermissionRequest{ + SessionID: "session-grant", + ToolCallID: "tool-call-grant", + ToolName: "bash", + Action: "execute", + Path: "/tmp/script.sh", + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + result := service.Request(req) + require.True(t, result, "Request should be granted") + }() + + event := <-events + calls := notifier.Calls() + require.Len(t, calls, 1) + assert.Equal(t, "💘 Crush is waiting", calls[0].title) + assert.Equal(t, "Permission required to execute \"bash\"", calls[0].message) + assert.Equal(t, permissionNotificationDelay, calls[0].delay) + + service.Grant(event.Payload) + wg.Wait() + + calls = notifier.Calls() + require.Len(t, calls, 1) + assert.Equal(t, 1, calls[0].cancelCount) +} + +func TestPermissionService_NotifyInteractionCancelsNotification(t *testing.T) { + notifier := &testPermissionNotifier{} + service := NewPermissionService(t.Context(), "/tmp", false, []string{}, notifier) + + events := service.Subscribe(t.Context()) + req := CreatePermissionRequest{ + SessionID: "session-interact", + ToolCallID: "tool-call-interact", + ToolName: "edit", + Action: "write", + Path: "/tmp/file.txt", + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + result := service.Request(req) + require.False(t, result, "Request should be denied") + }() + + event := <-events + calls := notifier.Calls() + require.Len(t, calls, 1) + + service.NotifyInteraction(event.Payload.ToolCallID) + calls = notifier.Calls() + require.Len(t, calls, 1) + assert.Equal(t, 1, calls[0].cancelCount) + + service.Deny(event.Payload) + wg.Wait() +} + +type notificationCall struct { + title string + message string + delay time.Duration + cancelCount int +} + +type testPermissionNotifier struct { + mu sync.Mutex + calls []*notificationCall +} + +func (n *testPermissionNotifier) NotifyPermissionRequest(ctx context.Context, title, message string, delay time.Duration) context.CancelFunc { + call := ¬ificationCall{ + title: title, + message: message, + delay: delay, + } + n.mu.Lock() + n.calls = append(n.calls, call) + n.mu.Unlock() + + return func() { + n.mu.Lock() + call.cancelCount++ + n.mu.Unlock() + } +} + +func (n *testPermissionNotifier) Calls() []notificationCall { + n.mu.Lock() + defer n.mu.Unlock() + + result := make([]notificationCall, len(n.calls)) + for i, call := range n.calls { + result[i] = *call + } + return result +} diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 6d7660dc6be9ed3e10d2cab9cabf2c9960326a3a..59bab5da7dfb67bd0ecfc64123f332f244538ec1 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -130,6 +130,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return m, nil // Ignore clicks outside the component } if msg.Button == tea.MouseLeft { + if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } cmds = append(cmds, m.handleMouseClick(x, y)) return m, tea.Batch(cmds...) } @@ -149,6 +152,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return m, nil // Ignore clicks outside the component } if msg.Button == tea.MouseLeft { + if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } m.listCmp.EndSelection(x, y) } return m, tea.Batch(cmds...) @@ -186,6 +192,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { if msg.endSelection { m.listCmp.EndSelection(msg.x, msg.y) } + if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } m.listCmp.SelectionStop() cmds = append(cmds, m.CopySelectedText(true)) return m, tea.Batch(cmds...) @@ -208,6 +217,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return m, tea.Batch(cmds...) case tea.MouseWheelMsg: + if m.app != nil && m.app.AgentCoordinator != nil && m.session.ID != "" && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } u, cmd := m.listCmp.Update(msg) m.listCmp = u.(list.List[list.Item]) cmds = append(cmds, cmd) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index de1a98b34595613594e83063cc12add4ba820c84..9fab52d7d5961ff91a8a5bed387e79bdcdabdb33 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -215,11 +215,18 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) { return m, util.ReportWarn("Agent is working, please wait...") } + if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } return m, m.openEditor(m.textarea.Value()) case OpenEditorMsg: m.textarea.SetValue(msg.Text) m.textarea.MoveToEnd() case tea.PasteMsg: + // Interaction: cancel any pending turn-end notification for this session. + if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } path := strings.ReplaceAll(msg.Content, "\\ ", " ") // try to get an image path, err := filepath.Abs(strings.TrimSpace(path)) @@ -261,6 +268,10 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.setEditorPrompt() return m, nil case tea.KeyPressMsg: + // Interaction: cancel any pending turn-end notification for this session. + if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } cur := m.textarea.Cursor() curIdx := m.textarea.Width()*cur.Y + cur.X switch { diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 9b1810f87c1bb20d2ccb96bdaaac046f2d9a5037..6b5105d63f4bc91702f934df178a82a4c14fabae 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -37,6 +37,11 @@ type PermissionResponseMsg struct { Action PermissionAction } +// PermissionInteractionMsg signals that the user has interacted with the permission dialog +type PermissionInteractionMsg struct { + ToolCallID string +} + // PermissionDialogCmp interface for permission dialog component type PermissionDialogCmp interface { dialogs.DialogModel @@ -68,6 +73,11 @@ type permissionDialogCmp struct { finalDialogHeight int keyMap KeyMap + + // hasInteracted prevents a stream of redundant interaction events for every scroll + // when we only care about the first event for notification purposes. If we later care + // about all interaction events, maybe the flag should be removed. + hasInteracted bool } func NewPermissionDialogCmp(permission permission.PermissionRequest, opts *Options) PermissionDialogCmp { @@ -97,6 +107,7 @@ func (p *permissionDialogCmp) supportsDiffView() bool { func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { var cmds []tea.Cmd + var interactionCmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -106,29 +117,29 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { cmd := p.SetSize() cmds = append(cmds, cmd) case tea.KeyPressMsg: + interactionCmd = p.emitInteraction() switch { case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab): p.selectedOption = (p.selectedOption + 1) % 3 - return p, nil case key.Matches(msg, p.keyMap.Left): p.selectedOption = (p.selectedOption + 2) % 3 case key.Matches(msg, p.keyMap.Select): - return p, p.selectCurrentOption() + cmds = append(cmds, p.selectCurrentOption()) case key.Matches(msg, p.keyMap.Allow): - return p, tea.Batch( + cmds = append(cmds, tea.Batch( util.CmdHandler(dialogs.CloseDialogMsg{}), util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}), - ) + )) case key.Matches(msg, p.keyMap.AllowSession): - return p, tea.Batch( + cmds = append(cmds, tea.Batch( util.CmdHandler(dialogs.CloseDialogMsg{}), util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}), - ) + )) case key.Matches(msg, p.keyMap.Deny): - return p, tea.Batch( + cmds = append(cmds, tea.Batch( util.CmdHandler(dialogs.CloseDialogMsg{}), util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}), - ) + )) case key.Matches(msg, p.keyMap.ToggleDiffMode): if p.supportsDiffView() { if p.diffSplitMode == nil { @@ -138,27 +149,22 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { *p.diffSplitMode = !*p.diffSplitMode } p.contentDirty = true // Mark content as dirty when diff mode changes - return p, nil } case key.Matches(msg, p.keyMap.ScrollDown): if p.supportsDiffView() { p.scrollDown() - return p, nil } case key.Matches(msg, p.keyMap.ScrollUp): if p.supportsDiffView() { p.scrollUp() - return p, nil } case key.Matches(msg, p.keyMap.ScrollLeft): if p.supportsDiffView() { p.scrollLeft() - return p, nil } case key.Matches(msg, p.keyMap.ScrollRight): if p.supportsDiffView() { p.scrollRight() - return p, nil } default: // Pass other keys to viewport @@ -168,6 +174,7 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } case tea.MouseWheelMsg: if p.supportsDiffView() && p.isMouseOverDialog(msg.Mouse().X, msg.Mouse().Y) { + interactionCmd = p.emitInteraction() switch msg.Button { case tea.MouseWheelDown: p.scrollDown() @@ -181,6 +188,10 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } } + if interactionCmd != nil { + cmds = append(cmds, interactionCmd) + } + return p, tea.Batch(cmds...) } @@ -204,6 +215,16 @@ func (p *permissionDialogCmp) scrollRight() { p.contentDirty = true } +func (p *permissionDialogCmp) emitInteraction() tea.Cmd { + if p.hasInteracted { + return nil + } + p.hasInteracted = true + return util.CmdHandler(PermissionInteractionMsg{ + ToolCallID: p.permission.ToolCallID, + }) +} + // isMouseOverDialog checks if the given mouse coordinates are within the dialog bounds. // Returns true if the mouse is over the dialog area, false otherwise. func (p *permissionDialogCmp) isMouseOverDialog(x, y int) bool { diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index f951de8677271dfbf034377afaa492f0d8824889..263e6cd42bbd35e7ed3b4d214932ad4aba92736b 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -174,6 +174,10 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { msg.Y -= 1 } if p.isMouseOverChat(msg.X, msg.Y) { + // Interaction: cancel any pending turn-end notification for this session. + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } u, cmd := p.chat.Update(msg) p.chat = u.(chat.MessageListCmp) return p, cmd @@ -246,6 +250,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { cmds = append(cmds, cmd) return p, tea.Batch(cmds...) case commands.ToggleCompactModeMsg: + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } p.forceCompact = !p.forceCompact var cmd tea.Cmd if p.forceCompact { @@ -257,8 +264,14 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { } return p, tea.Batch(p.SetSize(p.width, p.height), cmd) case commands.ToggleThinkingMsg: + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } return p, p.toggleThinking() case commands.OpenReasoningDialogMsg: + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } return p, p.openReasoningDialog() case reasoning.ReasoningEffortSelectedMsg: return p, p.handleReasoningEffortSelected(msg.Effort) @@ -316,6 +329,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { return p, tea.Batch(cmds...) case commands.ToggleYoloModeMsg: // update the editor style + if p.app != nil && p.app.AgentCoordinator != nil && p.session.ID != "" && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } u, cmd := p.editor.Update(msg) p.editor = u.(editor.Editor) return p, cmd @@ -360,6 +376,11 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { } return p, p.newSession() case tea.KeyPressMsg: + // If the chat pane is focused, any key-based navigation in chat counts + // as interaction; cancel any pending turn-end notification. + if p.focusedPane == PanelTypeChat && p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } switch { case key.Matches(msg, p.keyMap.NewSession): // if we have no agent do nothing @@ -374,6 +395,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { agentCfg := config.Get().Agents[config.AgentCoder] model := config.Get().GetModelByType(agentCfg.Model) if model.SupportsImages { + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } return p, util.CmdHandler(commands.OpenFilePickerMsg{}) } else { return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name) @@ -384,6 +408,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { p.splash = u.(splash.Splash) return p, cmd } + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } p.changeFocus() return p, nil case key.Matches(msg, p.keyMap.Cancel): @@ -391,6 +418,10 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { return p, p.cancel() } case key.Matches(msg, p.keyMap.Details): + // Opening/closing the sidebar counts as interaction; cancel pending notification. + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } p.toggleDetails() return p, nil } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e4eea700a4c109fa291fbaad3dc764e6df23921b..14e5fc798ffd6c1f32e0fde5be2fa32575065d94 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -237,6 +237,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case commands.SwitchModelMsg: + // Opening model dialog is interaction; cancel pending turn-end notif. + if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) { + a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID) + } return a, util.CmdHandler( dialogs.OpenDialogMsg{ Model: models.NewModelDialogCmp(), @@ -319,6 +323,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.app.Permissions.Deny(msg.Permission) } return a, nil + case permissions.PermissionInteractionMsg: + // Notify that the user interacted with the permission dialog. + a.app.Permissions.NotifyInteraction(msg.ToolCallID) + return a, nil case splash.OnboardingCompleteMsg: item, ok := a.pages[a.currentPage] if !ok { @@ -485,6 +493,11 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { return a.handleWindowResize(a.wWidth, a.wHeight) // dialogs case key.Matches(msg, a.keyMap.Commands): + // Opening the command palette counts as interaction; cancel pending + // turn-end notification for the selected session if any. + if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) { + a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID) + } // if the app is not configured show no commands if !a.isConfigured { return nil @@ -517,6 +530,10 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { if !a.isConfigured { return nil } + // Opening sessions dialog is interaction; cancel pending turn-end notif. + if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) { + a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID) + } if a.dialog.ActiveDialogID() == sessions.SessionsDialogID { return util.CmdHandler(dialogs.CloseDialogMsg{}) } diff --git a/schema.json b/schema.json index 283c5cada4ae9445e2f8cab1ca6a43329646cfda..82a2751a3be05b119e041747e17d34d2b8998f5d 100644 --- a/schema.json +++ b/schema.json @@ -426,6 +426,11 @@ "CLAUDE.md", "docs/LLMs.md" ] + }, + "disable_notifications": { + "type": "boolean", + "description": "Disable desktop notifications", + "default": false } }, "additionalProperties": false,