From 2df4b2eb5ec95cd58484862b9621961bf2c5a08d Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 31 Oct 2025 13:59:40 -0600 Subject: [PATCH] feat(notification): completion/permission notifs Add desktop notification system with delayed reminders: - Native notifications via beeep library - Agent completion notifications (cancelled on new user message) - Permission request notifications (cancelled on scroll/navigation/grant/deny) - Configurable via DisableNotifications option Implementation: - Notifier interface for scheduling delayed notifications with cancellation - Agent schedules completion notifications on turn end - Permission service schedules reminders when requesting permission - Permission dialog emits interaction events on first user action - Both services track and cancel scheduled notifications per session/tool call Resolves: #1224 Resolves: #390 References: #230 Co-authored-by: Crush --- go.mod | 9 ++ go.sum | 24 ++++ internal/agent/agent.go | 72 +++++++++- internal/agent/common_test.go | 16 ++- internal/agent/coordinator.go | 29 +++-- internal/agent/tools/multiedit_test.go | 2 + internal/app/app.go | 12 +- internal/config/config.go | 1 + internal/notification/notification.go | 77 +++++++++++ internal/notification/notification_test.go | 74 +++++++++++ internal/permission/permission.go | 61 ++++++++- internal/permission/permission_test.go | 123 +++++++++++++++++- .../dialogs/permissions/permissions.go | 47 +++++-- internal/tui/tui.go | 4 + schema.json | 5 + 15 files changed, 523 insertions(+), 33 deletions(-) create mode 100644 internal/notification/notification.go create mode 100644 internal/notification/notification_test.go diff --git a/go.mod b/go.mod index 79ff03750645aa557fcc524c3db20f2e1eceea81..b36df59f02f4ef47c5da94123975665c3d147600 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/charmbracelet/x/term v0.2.1 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 @@ -66,6 +67,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/andybalholm/cascadia v1.3.3 // indirect @@ -97,12 +99,15 @@ 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-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/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 @@ -112,6 +117,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/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/klauspost/pgzip v1.2.6 // indirect @@ -129,10 +135,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.9.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 6c07c06a54da857cd6c0feb849254aa5381a8aea..cec0c129339e94fabb094aeb3daa9a14177f8813 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,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= @@ -136,11 +138,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-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -148,10 +154,14 @@ 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/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= @@ -182,6 +192,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= @@ -267,6 +279,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= @@ -284,11 +300,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.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -389,6 +412,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 5a70195ce8e3bd1cbb06af3ce8be50e6b3a2c58e..1d0ca110f9fdbd521aa295c231b241f0dc149771 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -23,6 +23,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" ) @@ -60,6 +61,8 @@ type SessionAgent interface { Model() Model } +const completionNotificationDelay = 5 * time.Second + type Model struct { Model fantasy.LanguageModel CatwalkCfg catwalk.Model @@ -77,8 +80,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 { @@ -91,11 +97,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, @@ -108,6 +121,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](), } } @@ -119,6 +135,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) @@ -357,6 +375,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{ @@ -462,6 +483,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 @@ -492,6 +514,38 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy return a.Run(ctx, firstQueuedMessage) } +func (a *sessionAgent) scheduleCompletionNotification(sessionID, sessionTitle string) { + 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() + } +} + func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fantasy.ProviderOptions) error { if a.IsSessionBusy(sessionID) { return ErrSessionBusy @@ -804,6 +858,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() { @@ -819,6 +880,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 f6f564109a32c278f6e127809b9b2ef550c239bd..eac323fd0aa3c9e3930b12adfa326cc3d2735e10 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -114,7 +114,7 @@ func testEnv(t *testing.T) env { 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]() @@ -148,7 +148,19 @@ func testSessionAgent(env env, large, small fantasy.LanguageModel, systemPrompt 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 3fd57c69d2b90ecf1477eccda399cbfceb3c0ef6..db92e49386285908aac47a5765af47f31eeb1d43 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" @@ -61,6 +62,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 +79,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 +91,8 @@ func NewCoordinator( permissions: permissions, history: history, lspClients: lspClients, + notifier: notifier, + notifyCtx: ctx, agents: make(map[string]SessionAgent), } @@ -287,15 +296,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 a801f70a5feccac655209ac5c36deaae7e38592b..2c4d01f4079b3e276ec6d8a847902d1cef8759c6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -21,6 +21,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" @@ -34,6 +35,7 @@ type App struct { Permissions permission.Service AgentCoordinator agent.Coordinator + Notifier *notification.Notifier LSPClients *csync.Map[string, *lsp.Client] @@ -61,11 +63,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, @@ -276,6 +285,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 664ffadf737bd4ebf86393eef69b55baa7b0ac0d..cbb2f20bcb2090063a3520548020f04d839d2614 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -183,6 +183,7 @@ type Options struct { DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"` 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"` + 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 77b2526a592d0d194f75fb71af05477ae75df80b..c82ba223a3d2e188d19cc864c057531d70e8905a 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, @@ -198,6 +214,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool { // Publish the request s.Publish(pubsub.CreatedEvent, permission) + s.schedulePermissionNotification(permission) return <-respCh } @@ -212,6 +229,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 +245,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 +263,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..e36700771af6968bb780c39be4ca91cbbe92baa5 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,7 +183,7 @@ 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()) @@ -245,3 +248,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/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 5f6397810a7f7dbbd38b18bd0bb05a88d155985b..1446ba8ebc7166e5034f6bacb27f17c1a7589fc7 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/tui.go b/internal/tui/tui.go index d2e36764c573ecc6a783e1148e07cb28934b7019..d9dc0eb4e502a7a01c258e6a5c1e2f3dcf29ed46 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -270,6 +270,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 { diff --git a/schema.json b/schema.json index 74cf3b8c3f8005629b978bf7df17e25a74cce5d6..c3243040d1d775f5f7b77a39ef04e9cc09e578ca 100644 --- a/schema.json +++ b/schema.json @@ -395,6 +395,11 @@ "type": "boolean", "description": "Disable sending metrics", "default": false + }, + "disable_notifications": { + "type": "boolean", + "description": "Disable desktop notifications", + "default": false } }, "additionalProperties": false,