Detailed changes
@@ -30,6 +30,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
@@ -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
@@ -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=
@@ -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 {
@@ -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
}
@@ -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)
@@ -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]
}
@@ -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)
@@ -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
@@ -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
+}
@@ -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()
+ })
+ })
+}
@@ -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()
+ }
+}
@@ -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
+}
@@ -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 {
@@ -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 {
@@ -395,6 +395,11 @@
"type": "boolean",
"description": "Disable sending metrics",
"default": false
+ },
+ "disable_notifications": {
+ "type": "boolean",
+ "description": "Disable desktop notifications",
+ "default": false
}
},
"additionalProperties": false,