diff --git a/README.md b/README.md index 6e167345dd92ffb7a4d56241e9da7258a7c89b97..b1fcc099bf9ab4eff064927cd31ca9ef7c6975c3 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,25 @@ git clone https://github.com/anthropics/skills.git _temp mv _temp/skills/* . ; rm -r -force _temp ``` +### Desktop notifications + +Crush sends desktop notifications when a tool call requires permission and when +the agent finishes its turn. They're only sent when the terminal window isn't +focused _and_ your terminal supports reporting the focus state. + +```jsonc +{ + "$schema": "https://charm.land/crush.json", + "options": { + "disable_notifications": false // default + } +} +``` + +To disable desktop notifications, set `disable_notifications` to `true` in your +configuration. On macOS, notifications currently lack icons due to platform +limitations. + ### Initialization When you initialize a project, Crush analyzes your codebase and creates diff --git a/go.mod b/go.mod index 4ea501fd125ce2a8ef62b4555229218b5d65ff19..83fba8496267862f9648daf04dd8fa13232e6b58 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.1 + github.com/gen2brain/beeep v0.11.2 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 @@ -79,6 +80,7 @@ require ( cloud.google.com/go/auth v0.18.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect github.com/RealAlexandreAI/json-repair v0.0.14 // indirect @@ -113,15 +115,18 @@ require ( github.com/disintegration/gift v1.1.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // 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-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.3.0 // indirect @@ -132,6 +137,7 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/kaptinlin/go-i18n v0.2.2 // indirect github.com/kaptinlin/jsonpointer v0.4.8 // indirect github.com/kaptinlin/jsonschema v0.6.6 // indirect @@ -154,10 +160,13 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sergeymakinen/go-bmp v1.0.0 // indirect + github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/tetratelabs/wazero v1.11.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect diff --git a/go.sum b/go.sum index af4bd1eaab7bb4696ef0d344113a435f90a7a4ac..06fce87620f6d646ce4d2e31c728982485aacd1b 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= +git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= @@ -163,11 +165,15 @@ github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8k github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= +github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA= +github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -177,6 +183,8 @@ 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= @@ -185,6 +193,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= @@ -219,6 +229,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/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4= @@ -317,6 +329,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= @@ -334,11 +350,18 @@ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 20ca25f89421b8f1fd2927b1162c412d56becdc4..c66da4c918859d9ff4db2075546edc2b142030fa 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -39,6 +39,7 @@ import ( "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/stringext" + "github.com/charmbracelet/crush/internal/ui/notification" "github.com/charmbracelet/x/exp/charmtone" ) @@ -71,6 +72,7 @@ type SessionAgentCall struct { TopK *int64 FrequencyPenalty *float64 PresencePenalty *float64 + NonInteractive bool } type SessionAgent interface { @@ -107,6 +109,7 @@ type sessionAgent struct { messages message.Service disableAutoSummarize bool isYolo bool + notify notification.Sink messageQueue *csync.Map[string, []SessionAgentCall] activeRequests *csync.Map[string, context.CancelFunc] @@ -123,6 +126,7 @@ type SessionAgentOptions struct { Sessions session.Service Messages message.Service Tools []fantasy.AgentTool + Notify notification.Sink } func NewSessionAgent( @@ -139,6 +143,7 @@ func NewSessionAgent( disableAutoSummarize: opts.DisableAutoSummarize, tools: csync.NewSliceFrom(opts.Tools), isYolo: opts.IsYolo, + notify: opts.Notify, messageQueue: csync.NewMap[string, []SessionAgentCall](), activeRequests: csync.NewMap[string, context.CancelFunc](), } @@ -526,6 +531,15 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy return nil, err } + // Send notification that agent has finished its turn (skip for + // nested/non-interactive sessions). + if !call.NonInteractive && a.notify != nil { + a.notify(notification.Notification{ + Title: "Crush is waiting...", + Message: fmt.Sprintf("Agent's turn completed in \"%s\"", currentSession.Title), + }) + } + if shouldSummarize { a.activeRequests.Del(call.SessionID) if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil { diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go index 29566b1c5a00d00c1254a3f07cdcef71ba55d59e..89b3e7f7d6a52f8bd578beeb90f99034ac12ee7e 100644 --- a/internal/agent/agent_tool.go +++ b/internal/agent/agent_tool.go @@ -81,6 +81,7 @@ func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) TopK: model.ModelCfg.TopK, FrequencyPenalty: model.ModelCfg.FrequencyPenalty, PresencePenalty: model.ModelCfg.PresencePenalty, + NonInteractive: true, }) if err != nil { return fantasy.NewTextErrorResponse("error generating response"), nil diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 08da0e870187f537c9c88ac6a2b6ada97ff6fc88..fb7d5e631fd16bcafaa62028e7a99a963feacfb9 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -207,6 +207,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( TopK: small.ModelCfg.TopK, FrequencyPenalty: small.ModelCfg.FrequencyPenalty, PresencePenalty: small.ModelCfg.PresencePenalty, + NonInteractive: true, }) if err != nil { return fantasy.NewTextErrorResponse("error generating response"), nil diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 4f96c3cfbb1728f533c71a7c05b7e1ab85975b45..e6e61474571ee419ab329c87f76fb364c594510a 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -153,7 +153,15 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro DefaultMaxTokens: 10000, }, } - agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, false, true, env.sessions, env.messages, tools}) + agent := NewSessionAgent(SessionAgentOptions{ + LargeModel: largeModel, + SmallModel: smallModel, + SystemPrompt: systemPrompt, + IsYolo: true, + Sessions: env.sessions, + Messages: env.messages, + Tools: tools, + }) return agent } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 60da01e08c668f641c11f79c36c29b5fc2186c78..fa8784f60bde08f697550eceea4c2980ce6c28d1 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -30,6 +30,7 @@ import ( "github.com/charmbracelet/crush/internal/oauth/copilot" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/notification" "golang.org/x/sync/errgroup" "charm.land/fantasy/providers/anthropic" @@ -67,6 +68,7 @@ type coordinator struct { history history.Service filetracker filetracker.Service lspClients *csync.Map[string, *lsp.Client] + notify notification.Sink currentAgent SessionAgent agents map[string]SessionAgent @@ -83,6 +85,7 @@ func NewCoordinator( history history.Service, filetracker filetracker.Service, lspClients *csync.Map[string, *lsp.Client], + notify notification.Sink, ) (Coordinator, error) { c := &coordinator{ cfg: cfg, @@ -92,6 +95,7 @@ func NewCoordinator( history: history, filetracker: filetracker, lspClients: lspClients, + notify: notify, agents: make(map[string]SessionAgent), } @@ -333,16 +337,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, - "", - isSubAgent, - c.cfg.Options.DisableAutoSummarize, - c.permissions.SkipRequests(), - c.sessions, - c.messages, - nil, + LargeModel: large, + SmallModel: small, + SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix, + SystemPrompt: "", + IsSubAgent: isSubAgent, + DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize, + IsYolo: c.permissions.SkipRequests(), + Sessions: c.sessions, + Messages: c.messages, + Tools: nil, + Notify: c.notify, }) c.readyWg.Go(func() error { diff --git a/internal/app/app.go b/internal/app/app.go index c5294c2ae21f91486861a037b639cb1c00bd531f..0f15ea06dff649e0d4e7be5387afa3a3dfc855db 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -35,6 +35,7 @@ import ( "github.com/charmbracelet/crush/internal/shell" "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/ui/notification" "github.com/charmbracelet/crush/internal/update" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/x/ansi" @@ -68,8 +69,9 @@ type App struct { tuiWG *sync.WaitGroup // global context and cleanup functions - globalCtx context.Context - cleanupFuncs []func() error + globalCtx context.Context + cleanupFuncs []func() error + notifications chan notification.Notification } // New initializes a new application instance. @@ -99,6 +101,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { events: make(chan tea.Msg, 100), serviceEventsWG: &sync.WaitGroup{}, tuiWG: &sync.WaitGroup{}, + notifications: make(chan notification.Notification, 1), } app.setupEvents() @@ -133,6 +136,16 @@ func (app *App) Config() *config.Config { return app.config } +// Notifications returns the channel for receiving notification requests. +func (app *App) Notifications() <-chan notification.Notification { + return app.notifications +} + +// NotifySink returns a Sink function for publishing notifications. +func (app *App) NotifySink() notification.Sink { + return notification.NewChannelSink(app.notifications) +} + // RunNonInteractive runs the application in non-interactive mode with the // given prompt, printing to stdout. func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error { @@ -472,6 +485,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error { app.History, app.FileTracker, app.LSPClients, + app.NotifySink(), ) 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 19133928bd8f7e1da08b54024b4f80d41d01dc1a..604e449ee389fbae3eac7845222ac83701ca61af 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -259,6 +259,7 @@ type Options struct { 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"` AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"` Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"` + DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"` } type MCPs map[string]MCPConfig diff --git a/internal/permission/permission.go b/internal/permission/permission.go index fc47b7dc93869a1b0a39d30ddb0e408ce479429f..a5d238b379137362dba4989a954f0b1fbb84979e 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -134,6 +134,12 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe return true, nil } + // 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, nil + } + // tell the UI that a permission was requested s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{ ToolCallID: opts.ToolCallID, @@ -141,12 +147,6 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe 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, nil - } - s.autoApproveSessionsMu.RLock() autoApprove := s.autoApproveSessions[opts.SessionID] s.autoApproveSessionsMu.RUnlock() diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9a51a2497f09875d743e1051465dec7c1ac46e67..85fcc92dc3ae7ff8ceec7e09a9f256e26b757d0c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -594,6 +594,7 @@ func (a *appModel) View() tea.View { view.MouseMode = tea.MouseModeCellMotion view.BackgroundColor = t.BgBase view.WindowTitle = "crush " + home.Short(config.Get().WorkingDir()) + view.ReportFocus = true if a.wWidth < 25 || a.wHeight < 15 { view.Content = t.S().Base.Width(a.wWidth).Height(a.wHeight). Align(lipgloss.Center, lipgloss.Center). diff --git a/internal/ui/common/capabilities.go b/internal/ui/common/capabilities.go index 6636976d7d4f86d9283be2db759b44f948ad40f5..fb8ec2e7d56ff8cb0827ae9dc0cd642adf8d2434 100644 --- a/internal/ui/common/capabilities.go +++ b/internal/ui/common/capabilities.go @@ -58,7 +58,7 @@ func (c *Capabilities) Update(msg any) { } case tea.TerminalVersionMsg: c.TerminalVersion = m.Name - case uv.ModeReportEvent: + case tea.ModeReportMsg: switch m.Mode { case ansi.ModeFocusEvent: c.ReportFocusEvents = modeSupported(m.Value) @@ -76,7 +76,7 @@ func QueryCmd(env uv.Environ) tea.Cmd { shouldQueryFor := shouldQueryCapabilities(env) if shouldQueryFor { sb.WriteString(ansi.RequestNameVersion) - // sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications. + sb.WriteString(ansi.RequestModeFocusEvent) sb.WriteString(ansi.WindowOp(14)) // Window size in pixels kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24") if _, isTmux := env.LookupEnv("TMUX"); isTmux { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e26323f551c7099fd579c303b80f1b764a98f242..aead3c9876564c573c42e2d3c28fe5a806499a79 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -42,11 +42,13 @@ import ( "github.com/charmbracelet/crush/internal/ui/completions" "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/crush/internal/ui/logo" + "github.com/charmbracelet/crush/internal/ui/notification" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/uiutil" "github.com/charmbracelet/crush/internal/version" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/ultraviolet/screen" + "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/editor" ) @@ -109,6 +111,9 @@ type ( // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard. copyChatHighlightMsg struct{} + + // notificationRequestMsg is sent when a notification request arrives. + notificationRequestMsg notification.Notification ) // UI represents the main user interface model. @@ -183,6 +188,10 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string + // Notification state + notifyBackend notification.Backend + notifyWindowFocused bool + notifyCh <-chan notification.Notification // custom commands & mcp commands customCommands []commands.CustomCommand mcpPrompts []commands.MCPPrompt @@ -260,16 +269,19 @@ func New(com *common.Common) *UI { ) ui := &UI{ - com: com, - dialog: dialog.NewOverlay(), - keyMap: keyMap, - textarea: ta, - chat: ch, - completions: comp, - attachments: attachments, - todoSpinner: todoSpinner, - lspStates: make(map[string]app.LSPClientInfo), - mcpStates: make(map[string]mcp.ClientInfo), + com: com, + dialog: dialog.NewOverlay(), + keyMap: keyMap, + textarea: ta, + chat: ch, + completions: comp, + attachments: attachments, + todoSpinner: todoSpinner, + lspStates: make(map[string]app.LSPClientInfo), + mcpStates: make(map[string]mcp.ClientInfo), + notifyBackend: notification.NoopBackend{}, + notifyWindowFocused: true, + notifyCh: com.App.Notifications(), } status := NewStatus(com, ui) @@ -314,9 +326,48 @@ func (m *UI) Init() tea.Cmd { cmds = append(cmds, m.loadCustomCommands()) // load prompt history async cmds = append(cmds, m.loadPromptHistory()) + // start listening for notification requests + cmds = append(cmds, m.waitForNotification()) return tea.Batch(cmds...) } +// waitForNotification returns a command that waits for the next notification request. +func (m *UI) waitForNotification() tea.Cmd { + return func() tea.Msg { + n, ok := <-m.notifyCh + if !ok { + return nil + } + return notificationRequestMsg(n) + } +} + +// sendNotification returns a command that sends a notification if allowed by policy. +func (m *UI) sendNotification(n notification.Notification) tea.Cmd { + if !m.shouldSendNotification() { + return nil + } + + backend := m.notifyBackend + return func() tea.Msg { + if err := backend.Send(n); err != nil { + slog.Error("failed to send notification", "error", err) + } + return nil + } +} + +// shouldSendNotification returns true if notifications should be sent based on +// current state. Focus reporting must be supported, window must not focused, +// and notifications must not be disabled in config. +func (m *UI) shouldSendNotification() bool { + cfg := m.com.Config() + if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications { + return false + } + return m.caps.ReportFocusEvents && !m.notifyWindowFocused +} + // setState changes the UI state and focus. func (m *UI) setState(state uiState, focus uiFocusState) { m.state = state @@ -370,6 +421,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } cmds = append(cmds, common.QueryCmd(uv.Environ(msg))) + case tea.ModeReportMsg: + if msg.Mode == ansi.ModeFocusEvent && m.caps.ReportFocusEvents { + m.notifyBackend = notification.NewNativeBackend(notification.Icon) + } + case tea.FocusMsg: + m.notifyWindowFocused = true + case tea.BlurMsg: + m.notifyWindowFocused = false + case notificationRequestMsg: + // Re-subscribe for next notification. + cmds = append(cmds, m.waitForNotification()) + if cmd := m.sendNotification(notification.Notification(msg)); cmd != nil { + cmds = append(cmds, cmd) + } case loadSessionMsg: if m.forceCompactMode { m.isCompact = true @@ -502,6 +567,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil { cmds = append(cmds, cmd) } + if cmd := m.sendNotification(notification.Notification{ + Title: "Crush is waiting...", + Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName), + }); cmd != nil { + cmds = append(cmds, cmd) + } case pubsub.Event[permission.PermissionNotification]: m.handlePermissionNotification(msg.Payload) case cancelTimerExpiredMsg: @@ -1879,6 +1950,7 @@ func (m *UI) View() tea.View { v.AltScreen = true v.BackgroundColor = m.com.Styles.Background v.MouseMode = tea.MouseModeCellMotion + v.ReportFocus = m.caps.ReportFocusEvents v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir()) canvas := uv.NewScreenBuffer(m.width, m.height) diff --git a/internal/ui/notification/crush-icon-solo.png b/internal/ui/notification/crush-icon-solo.png new file mode 100644 index 0000000000000000000000000000000000000000..eed026660d0d5882c9b6e98912ee2afd9748f2a6 Binary files /dev/null and b/internal/ui/notification/crush-icon-solo.png differ diff --git a/internal/ui/notification/crush-icon.png b/internal/ui/notification/crush-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..138a7ab6d246a0989cf9fca296e7c7df78523465 Binary files /dev/null and b/internal/ui/notification/crush-icon.png differ diff --git a/internal/ui/notification/icon_darwin.go b/internal/ui/notification/icon_darwin.go new file mode 100644 index 0000000000000000000000000000000000000000..27df25009be6bb849afc7b39b631fbbe3c61b6b3 --- /dev/null +++ b/internal/ui/notification/icon_darwin.go @@ -0,0 +1,7 @@ +//go:build darwin + +package notification + +// Icon is currently empty on darwin because platform icon support is broken. Do +// use the icon for OSC notifications, just not native. +var Icon any = "" diff --git a/internal/ui/notification/icon_other.go b/internal/ui/notification/icon_other.go new file mode 100644 index 0000000000000000000000000000000000000000..27240ad93fc653c9e742a879e76914481e5f1d55 --- /dev/null +++ b/internal/ui/notification/icon_other.go @@ -0,0 +1,13 @@ +//go:build !darwin + +package notification + +import ( + _ "embed" +) + +//go:embed crush-icon-solo.png +var icon []byte + +// Icon contains the embedded PNG icon data for desktop notifications. +var Icon any = icon diff --git a/internal/ui/notification/native.go b/internal/ui/notification/native.go new file mode 100644 index 0000000000000000000000000000000000000000..ed97865690a01aa8bea96501a40cb49030eb7814 --- /dev/null +++ b/internal/ui/notification/native.go @@ -0,0 +1,49 @@ +package notification + +import ( + "log/slog" + + "github.com/gen2brain/beeep" +) + +// NativeBackend sends desktop notifications using the native OS notification +// system via beeep. +type NativeBackend struct { + // icon is the notification icon data (platform-specific). + icon any + // notifyFunc is the function used to send notifications (swappable for testing). + notifyFunc func(title, message string, icon any) error +} + +// NewNativeBackend creates a new native notification backend. +func NewNativeBackend(icon any) *NativeBackend { + beeep.AppName = "Crush" + return &NativeBackend{ + icon: icon, + notifyFunc: beeep.Notify, + } +} + +// Send sends a desktop notification using the native OS notification system. +func (b *NativeBackend) Send(n Notification) error { + slog.Debug("sending native notification", "title", n.Title, "message", n.Message) + + err := b.notifyFunc(n.Title, n.Message, b.icon) + if err != nil { + slog.Error("failed to send notification", "error", err) + } else { + slog.Debug("notification sent successfully") + } + + return err +} + +// SetNotifyFunc allows replacing the notification function for testing. +func (b *NativeBackend) SetNotifyFunc(fn func(title, message string, icon any) error) { + b.notifyFunc = fn +} + +// ResetNotifyFunc resets the notification function to the default. +func (b *NativeBackend) ResetNotifyFunc() { + b.notifyFunc = beeep.Notify +} diff --git a/internal/ui/notification/noop.go b/internal/ui/notification/noop.go new file mode 100644 index 0000000000000000000000000000000000000000..7e943e38af15ad4e2dcd47c95158bb4abcb6bb56 --- /dev/null +++ b/internal/ui/notification/noop.go @@ -0,0 +1,10 @@ +package notification + +// NoopBackend is a no-op notification backend that does nothing. +// This is the default backend used when notifications are not supported. +type NoopBackend struct{} + +// Send does nothing and returns nil. +func (NoopBackend) Send(_ Notification) error { + return nil +} diff --git a/internal/ui/notification/notification.go b/internal/ui/notification/notification.go new file mode 100644 index 0000000000000000000000000000000000000000..f6be12bfe8b84c2cf18b4c5f1ae3720e820e6cd5 --- /dev/null +++ b/internal/ui/notification/notification.go @@ -0,0 +1,15 @@ +// Package notification provides desktop notification support for the UI. +package notification + +// Notification represents a desktop notification request. +type Notification struct { + Title string + Message string +} + +// Backend defines the interface for sending desktop notifications. +// Implementations are pure transport - policy decisions (config, focus state) +// are handled by the caller. +type Backend interface { + Send(n Notification) error +} diff --git a/internal/ui/notification/notification_test.go b/internal/ui/notification/notification_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a1263d84df35a84a972cba6cb964277fa14cdf59 --- /dev/null +++ b/internal/ui/notification/notification_test.go @@ -0,0 +1,90 @@ +package notification_test + +import ( + "testing" + + "github.com/charmbracelet/crush/internal/ui/notification" + "github.com/stretchr/testify/require" +) + +func TestNoopBackend_Send(t *testing.T) { + t.Parallel() + + backend := notification.NoopBackend{} + err := backend.Send(notification.Notification{ + Title: "Test Title", + Message: "Test Message", + }) + require.NoError(t, err) +} + +func TestNativeBackend_Send(t *testing.T) { + t.Parallel() + + backend := notification.NewNativeBackend(nil) + + var capturedTitle, capturedMessage string + var capturedIcon any + backend.SetNotifyFunc(func(title, message string, icon any) error { + capturedTitle = title + capturedMessage = message + capturedIcon = icon + return nil + }) + + err := backend.Send(notification.Notification{ + Title: "Hello", + Message: "World", + }) + require.NoError(t, err) + require.Equal(t, "Hello", capturedTitle) + require.Equal(t, "World", capturedMessage) + require.Nil(t, capturedIcon) +} + +func TestChannelSink(t *testing.T) { + t.Parallel() + + ch := make(chan notification.Notification, 1) + sink := notification.NewChannelSink(ch) + + sink(notification.Notification{ + Title: "Test", + Message: "Notification", + }) + + select { + case n := <-ch: + require.Equal(t, "Test", n.Title) + require.Equal(t, "Notification", n.Message) + default: + t.Fatal("expected notification in channel") + } +} + +func TestChannelSink_FullChannel(t *testing.T) { + t.Parallel() + + // Create a full channel (buffer of 1, already has 1 item). + ch := make(chan notification.Notification, 1) + ch <- notification.Notification{Title: "First", Message: "First"} + + sink := notification.NewChannelSink(ch) + + // This should not block; it drains the old notification and sends the new. + sink(notification.Notification{ + Title: "Second", + Message: "Second", + }) + + // The second notification should replace the first (drain-before-send). + n := <-ch + require.Equal(t, "Second", n.Title) + + select { + case <-ch: + t.Fatal("expected channel to be empty") + default: + // Expected. + } +} diff --git a/internal/ui/notification/sink.go b/internal/ui/notification/sink.go new file mode 100644 index 0000000000000000000000000000000000000000..530414670af3baed0243c17a6e73d11f81151128 --- /dev/null +++ b/internal/ui/notification/sink.go @@ -0,0 +1,27 @@ +package notification + +// Sink is a function that accepts notification requests. +// This allows agents to publish notifications without knowing about the UI. +type Sink func(Notification) + +// NewChannelSink creates a Sink that sends notifications to a channel. The +// channel should have a buffer of 1. +// +// Any pending notification is discarded before sending the new one. This +// ensures the consumer always sees the most recent notification rather +// than a potential barrage when only one is needed. +func NewChannelSink(ch chan Notification) Sink { + return func(n Notification) { + // Drain any existing notification. + select { + case <-ch: + default: + } + // Send the new notification. The channel should be empty, but it uses a + // non-blocking send for safety in case of a race with the consumer. + select { + case ch <- n: + default: + } + } +} diff --git a/schema.json b/schema.json index 7a32f612e64a20d0393f74471c1fbdb8863c2365..150fbb2c2d698a12254891b53ed48f08ee2b1355 100644 --- a/schema.json +++ b/schema.json @@ -439,6 +439,11 @@ "type": "boolean", "description": "Show indeterminate progress updates during long operations", "default": true + }, + "disable_notifications": { + "type": "boolean", + "description": "Disable desktop notifications", + "default": false } }, "additionalProperties": false,