on main help sign in
feat(notification): alert on turn completion and permission request (#1356)
f8da538Amolith
README.md+19
@@ -424,6 +424,25 @@ git clone https://github.com/anthropics/skills.git _temp
424mv _temp/skills/* . ; rm -r -force _temp
424mv _temp/skills/* . ; rm -r -force _temp
425```
425```
426
426
427+### Desktop notifications
428+
429+Crush sends desktop notifications when a tool call requires permission and when
430+the agent finishes its turn. They're only sent when the terminal window isn't
431+focused _and_ your terminal supports reporting the focus state.
432+
433+```jsonc
434+{
435+ "$schema": "https://charm.land/crush.json",
436+ "options": {
437+ "disable_notifications": false // default
438+ }
439+}
440+```
441+
442+To disable desktop notifications, set `disable_notifications` to `true` in your
443+configuration. On macOS, notifications currently lack icons due to platform
444+limitations.
445+
427### Initialization
446### Initialization
428
447
429When you initialize a project, Crush analyzes your codebase and creates
448When you initialize a project, Crush analyzes your codebase and creates
go.mod+10
@@ -38,6 +38,7 @@ require (
38 github.com/denisbrodbeck/machineid v1.0.1
38 github.com/denisbrodbeck/machineid v1.0.1
39 github.com/disintegration/imaging v1.6.2
39 github.com/disintegration/imaging v1.6.2
40 github.com/dustin/go-humanize v1.0.1
40 github.com/dustin/go-humanize v1.0.1
41+ github.com/gen2brain/beeep v0.11.2
41 github.com/go-git/go-git/v5 v5.17.0
42 github.com/go-git/go-git/v5 v5.17.0
42 github.com/google/uuid v1.6.0
43 github.com/google/uuid v1.6.0
43 github.com/invopop/jsonschema v0.13.0
44 github.com/invopop/jsonschema v0.13.0
@@ -77,6 +78,7 @@ require (
77 cloud.google.com/go/auth v0.18.2 // indirect
78 cloud.google.com/go/auth v0.18.2 // indirect
78 cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
79 cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
79 cloud.google.com/go/compute/metadata v0.9.0 // indirect
80 cloud.google.com/go/compute/metadata v0.9.0 // indirect
81+ git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
80 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
82 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
81 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
83 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
82 github.com/andybalholm/cascadia v1.3.3 // indirect
84 github.com/andybalholm/cascadia v1.3.3 // indirect
@@ -106,6 +108,7 @@ require (
106 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
108 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
107 github.com/dlclark/regexp2 v1.11.5 // indirect
109 github.com/dlclark/regexp2 v1.11.5 // indirect
108 github.com/ebitengine/purego v0.10.0 // indirect
110 github.com/ebitengine/purego v0.10.0 // indirect
111+ github.com/esiqveland/notify v0.13.3 // indirect
109 github.com/felixge/httpsnoop v1.0.4 // indirect
112 github.com/felixge/httpsnoop v1.0.4 // indirect
110 github.com/fsnotify/fsnotify v1.9.0 // indirect
113 github.com/fsnotify/fsnotify v1.9.0 // indirect
111 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
114 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
@@ -114,9 +117,11 @@ require (
114 github.com/go-logfmt/logfmt v0.6.0 // indirect
117 github.com/go-logfmt/logfmt v0.6.0 // indirect
115 github.com/go-logr/logr v1.4.3 // indirect
118 github.com/go-logr/logr v1.4.3 // indirect
116 github.com/go-logr/stdr v1.2.2 // indirect
119 github.com/go-logr/stdr v1.2.2 // indirect
120+ github.com/go-ole/go-ole v1.3.0 // indirect
117 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
121 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
118 github.com/goccy/go-json v0.10.5 // indirect
122 github.com/goccy/go-json v0.10.5 // indirect
119 github.com/goccy/go-yaml v1.19.2 // indirect
123 github.com/goccy/go-yaml v1.19.2 // indirect
124+ github.com/godbus/dbus/v5 v5.2.2 // indirect
120 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
125 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
121 github.com/google/go-cmp v0.7.0 // indirect
126 github.com/google/go-cmp v0.7.0 // indirect
122 github.com/google/jsonschema-go v0.4.2 // indirect
127 github.com/google/jsonschema-go v0.4.2 // indirect
@@ -127,6 +132,7 @@ require (
127 github.com/gorilla/websocket v1.5.3 // indirect
132 github.com/gorilla/websocket v1.5.3 // indirect
128 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
133 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
129 github.com/inconshreveable/mousetrap v1.1.0 // indirect
134 github.com/inconshreveable/mousetrap v1.1.0 // indirect
135+ github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
130 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
136 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
131 github.com/kaptinlin/go-i18n v0.2.12 // indirect
137 github.com/kaptinlin/go-i18n v0.2.12 // indirect
132 github.com/kaptinlin/jsonpointer v0.4.17 // indirect
138 github.com/kaptinlin/jsonpointer v0.4.17 // indirect
@@ -147,13 +153,17 @@ require (
147 github.com/muesli/roff v0.1.0 // indirect
153 github.com/muesli/roff v0.1.0 // indirect
148 github.com/ncruces/go-strftime v1.0.0 // indirect
154 github.com/ncruces/go-strftime v1.0.0 // indirect
149 github.com/ncruces/julianday v1.0.0 // indirect
155 github.com/ncruces/julianday v1.0.0 // indirect
156+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
150 github.com/pierrec/lz4/v4 v4.1.25 // indirect
157 github.com/pierrec/lz4/v4 v4.1.25 // indirect
151 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
158 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
152 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
159 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
153 github.com/segmentio/asm v1.2.1 // indirect
160 github.com/segmentio/asm v1.2.1 // indirect
154 github.com/segmentio/encoding v0.5.3 // indirect
161 github.com/segmentio/encoding v0.5.3 // indirect
162+ github.com/sergeymakinen/go-bmp v1.0.0 // indirect
163+ github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
155 github.com/sethvargo/go-retry v0.3.0 // indirect
164 github.com/sethvargo/go-retry v0.3.0 // indirect
156 github.com/spf13/pflag v1.0.9 // indirect
165 github.com/spf13/pflag v1.0.9 // indirect
166+ github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
157 github.com/tetratelabs/wazero v1.11.0 // indirect
167 github.com/tetratelabs/wazero v1.11.0 // indirect
158 github.com/tidwall/match v1.1.1 // indirect
168 github.com/tidwall/match v1.1.1 // indirect
159 github.com/tidwall/pretty v1.2.1 // indirect
169 github.com/tidwall/pretty v1.2.1 // indirect
go.sum+27
@@ -22,6 +22,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
22cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
22cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
23cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
23cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
24cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
24cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
25+git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=
26+git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=
25github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
27github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
26github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
28github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
27github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
29github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
@@ -158,11 +160,15 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNf
158github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
160github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
159github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
161github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
160github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
162github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
163+github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=
164+github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE=
161github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
165github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
162github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
166github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
163github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
167github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
164github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
168github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
165github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
169github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
170+github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA=
171+github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc=
166github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
172github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
167github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
173github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
168github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
174github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
@@ -178,6 +184,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
178github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
184github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
179github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
185github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
180github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
186github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
187+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
188+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
181github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
189github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
182github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
190github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
183github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
191github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
@@ -186,6 +194,9 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
186github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
194github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
187github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
195github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
188github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
196github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
197+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
198+github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
199+github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
189github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
200github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
190github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
201github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
191github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
202github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
@@ -220,6 +231,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
220github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
231github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
221github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
232github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
222github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
233github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
234+github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o=
235+github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ=
223github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
236github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
224github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
237github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
225github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
238github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -282,6 +295,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
282github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
295github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
283github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
296github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
284github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
297github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
298+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
299+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
285github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
300github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
286github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
301github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
287github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
302github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
@@ -321,6 +336,10 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
321github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
336github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
322github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
337github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
323github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
338github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
339+github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=
340+github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
341+github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=
342+github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
324github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
343github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
325github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
344github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
326github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
345github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
@@ -334,10 +353,17 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
334github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
353github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
335github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
354github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
336github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
355github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
356+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
357+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
337github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
358github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
338github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
359github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
360+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
361+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
362+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
339github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
363github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
340github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
364github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
365+github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
366+github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
341github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
367github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
342github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
368github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
343github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
369github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -518,6 +544,7 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN
518gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
544gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
519gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
545gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
520gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
546gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
547+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
521gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
548gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
522gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
549gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
523modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
550modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
internal/agent/agent.go+16
@@ -32,12 +32,14 @@ import (
32 "charm.land/fantasy/providers/vercel"
32 "charm.land/fantasy/providers/vercel"
33 "charm.land/lipgloss/v2"
33 "charm.land/lipgloss/v2"
34 "github.com/charmbracelet/crush/internal/agent/hyper"
34 "github.com/charmbracelet/crush/internal/agent/hyper"
35+ "github.com/charmbracelet/crush/internal/agent/notify"
35 "github.com/charmbracelet/crush/internal/agent/tools"
36 "github.com/charmbracelet/crush/internal/agent/tools"
36 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
37 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
37 "github.com/charmbracelet/crush/internal/config"
38 "github.com/charmbracelet/crush/internal/config"
38 "github.com/charmbracelet/crush/internal/csync"
39 "github.com/charmbracelet/crush/internal/csync"
39 "github.com/charmbracelet/crush/internal/message"
40 "github.com/charmbracelet/crush/internal/message"
40 "github.com/charmbracelet/crush/internal/permission"
41 "github.com/charmbracelet/crush/internal/permission"
42+ "github.com/charmbracelet/crush/internal/pubsub"
41 "github.com/charmbracelet/crush/internal/session"
43 "github.com/charmbracelet/crush/internal/session"
42 "github.com/charmbracelet/crush/internal/stringext"
44 "github.com/charmbracelet/crush/internal/stringext"
43 "github.com/charmbracelet/crush/internal/version"
45 "github.com/charmbracelet/crush/internal/version"
@@ -73,6 +75,7 @@ type SessionAgentCall struct {
73 TopK *int64
75 TopK *int64
74 FrequencyPenalty *float64
76 FrequencyPenalty *float64
75 PresencePenalty *float64
77 PresencePenalty *float64
78+ NonInteractive bool
76}
79}
77
80
78type SessionAgent interface {
81type SessionAgent interface {
@@ -109,6 +112,7 @@ type sessionAgent struct {
109 messages message.Service
112 messages message.Service
110 disableAutoSummarize bool
113 disableAutoSummarize bool
111 isYolo bool
114 isYolo bool
115+ notify pubsub.Publisher[notify.Notification]
112
116
113 messageQueue *csync.Map[string, []SessionAgentCall]
117 messageQueue *csync.Map[string, []SessionAgentCall]
114 activeRequests *csync.Map[string, context.CancelFunc]
118 activeRequests *csync.Map[string, context.CancelFunc]
@@ -125,6 +129,7 @@ type SessionAgentOptions struct {
125 Sessions session.Service
129 Sessions session.Service
126 Messages message.Service
130 Messages message.Service
127 Tools []fantasy.AgentTool
131 Tools []fantasy.AgentTool
132+ Notify pubsub.Publisher[notify.Notification]
128}
133}
129
134
130func NewSessionAgent(
135func NewSessionAgent(
@@ -141,6 +146,7 @@ func NewSessionAgent(
141 disableAutoSummarize: opts.DisableAutoSummarize,
146 disableAutoSummarize: opts.DisableAutoSummarize,
142 tools: csync.NewSliceFrom(opts.Tools),
147 tools: csync.NewSliceFrom(opts.Tools),
143 isYolo: opts.IsYolo,
148 isYolo: opts.IsYolo,
149+ notify: opts.Notify,
144 messageQueue: csync.NewMap[string, []SessionAgentCall](),
150 messageQueue: csync.NewMap[string, []SessionAgentCall](),
145 activeRequests: csync.NewMap[string, context.CancelFunc](),
151 activeRequests: csync.NewMap[string, context.CancelFunc](),
146 }
152 }
@@ -532,6 +538,16 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
532 return nil, err
538 return nil, err
533 }
539 }
534
540
541+ // Send notification that agent has finished its turn (skip for
542+ // nested/non-interactive sessions).
543+ if !call.NonInteractive && a.notify != nil {
544+ a.notify.Publish(pubsub.CreatedEvent, notify.Notification{
545+ SessionID: call.SessionID,
546+ SessionTitle: currentSession.Title,
547+ Type: notify.TypeAgentFinished,
548+ })
549+ }
550+
535 if shouldSummarize {
551 if shouldSummarize {
536 a.activeRequests.Del(call.SessionID)
552 a.activeRequests.Del(call.SessionID)
537 if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
553 if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
internal/agent/common_test.go+9−1
@@ -153,7 +153,15 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro
153 DefaultMaxTokens: 10000,
153 DefaultMaxTokens: 10000,
154 },
154 },
155 }
155 }
156− agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, false, true, env.sessions, env.messages, tools})
156+ agent := NewSessionAgent(SessionAgentOptions{
157+ LargeModel: largeModel,
158+ SmallModel: smallModel,
159+ SystemPrompt: systemPrompt,
160+ IsYolo: true,
161+ Sessions: env.sessions,
162+ Messages: env.messages,
163+ Tools: tools,
164+ })
157 return agent
165 return agent
158}
166}
159
167
internal/agent/coordinator.go+17−10
@@ -18,6 +18,7 @@ import (
18 "charm.land/catwalk/pkg/catwalk"
18 "charm.land/catwalk/pkg/catwalk"
19 "charm.land/fantasy"
19 "charm.land/fantasy"
20 "github.com/charmbracelet/crush/internal/agent/hyper"
20 "github.com/charmbracelet/crush/internal/agent/hyper"
21+ "github.com/charmbracelet/crush/internal/agent/notify"
21 "github.com/charmbracelet/crush/internal/agent/prompt"
22 "github.com/charmbracelet/crush/internal/agent/prompt"
22 "github.com/charmbracelet/crush/internal/agent/tools"
23 "github.com/charmbracelet/crush/internal/agent/tools"
23 "github.com/charmbracelet/crush/internal/config"
24 "github.com/charmbracelet/crush/internal/config"
@@ -28,6 +29,7 @@ import (
28 "github.com/charmbracelet/crush/internal/message"
29 "github.com/charmbracelet/crush/internal/message"
29 "github.com/charmbracelet/crush/internal/oauth/copilot"
30 "github.com/charmbracelet/crush/internal/oauth/copilot"
30 "github.com/charmbracelet/crush/internal/permission"
31 "github.com/charmbracelet/crush/internal/permission"
32+ "github.com/charmbracelet/crush/internal/pubsub"
31 "github.com/charmbracelet/crush/internal/session"
33 "github.com/charmbracelet/crush/internal/session"
32 "golang.org/x/sync/errgroup"
34 "golang.org/x/sync/errgroup"
33
35
@@ -79,6 +81,7 @@ type coordinator struct {
79 history history.Service
81 history history.Service
80 filetracker filetracker.Service
82 filetracker filetracker.Service
81 lspManager *lsp.Manager
83 lspManager *lsp.Manager
84+ notify pubsub.Publisher[notify.Notification]
82
85
83 currentAgent SessionAgent
86 currentAgent SessionAgent
84 agents map[string]SessionAgent
87 agents map[string]SessionAgent
@@ -95,6 +98,7 @@ func NewCoordinator(
95 history history.Service,
98 history history.Service,
96 filetracker filetracker.Service,
99 filetracker filetracker.Service,
97 lspManager *lsp.Manager,
100 lspManager *lsp.Manager,
101+ notify pubsub.Publisher[notify.Notification],
98) (Coordinator, error) {
102) (Coordinator, error) {
99 c := &coordinator{
103 c := &coordinator{
100 cfg: cfg,
104 cfg: cfg,
@@ -104,6 +108,7 @@ func NewCoordinator(
104 history: history,
108 history: history,
105 filetracker: filetracker,
109 filetracker: filetracker,
106 lspManager: lspManager,
110 lspManager: lspManager,
111+ notify: notify,
107 agents: make(map[string]SessionAgent),
112 agents: make(map[string]SessionAgent),
108 }
113 }
109
114
@@ -380,16 +385,17 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
380
385
381 largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
386 largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
382 result := NewSessionAgent(SessionAgentOptions{
387 result := NewSessionAgent(SessionAgentOptions{
383− large,
388+ LargeModel: large,
384− small,
389+ SmallModel: small,
385− largeProviderCfg.SystemPromptPrefix,
390+ SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix,
386− "",
391+ SystemPrompt: "",
387− isSubAgent,
392+ IsSubAgent: isSubAgent,
388− c.cfg.Options.DisableAutoSummarize,
393+ DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
389− c.permissions.SkipRequests(),
394+ IsYolo: c.permissions.SkipRequests(),
390− c.sessions,
395+ Sessions: c.sessions,
391− c.messages,
396+ Messages: c.messages,
392− nil,
397+ Tools: nil,
398+ Notify: c.notify,
393 })
399 })
394
400
395 c.readyWg.Go(func() error {
401 c.readyWg.Go(func() error {
@@ -994,6 +1000,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f
994 TopK: model.ModelCfg.TopK,
1000 TopK: model.ModelCfg.TopK,
995 FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
1001 FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
996 PresencePenalty: model.ModelCfg.PresencePenalty,
1002 PresencePenalty: model.ModelCfg.PresencePenalty,
1003+ NonInteractive: true,
997 })
1004 })
998 if err != nil {
1005 if err != nil {
999 return fantasy.NewTextErrorResponse("error generating response"), nil
1006 return fantasy.NewTextErrorResponse("error generating response"), nil
internal/agent/notify/notify.gonew file+19
@@ -0,0 +1,19 @@
1+// Package notify defines domain notification types for agent events.
2+// These types are decoupled from UI concerns so the agent can publish
3+// events without importing UI packages.
4+package notify
5+
6+// Type identifies the kind of agent notification.
7+type Type string
8+
9+const (
10+ // TypeAgentFinished indicates the agent has completed its turn.
11+ TypeAgentFinished Type = "agent_finished"
12+)
13+
14+// Notification represents a domain event published by the agent.
15+type Notification struct {
16+ SessionID string
17+ SessionTitle string
18+ Type Type
19+}
internal/app/app.go+16−6
@@ -19,6 +19,7 @@ import (
19 "charm.land/fantasy"
19 "charm.land/fantasy"
20 "charm.land/lipgloss/v2"
20 "charm.land/lipgloss/v2"
21 "github.com/charmbracelet/crush/internal/agent"
21 "github.com/charmbracelet/crush/internal/agent"
22+ "github.com/charmbracelet/crush/internal/agent/notify"
22 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
23 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
23 "github.com/charmbracelet/crush/internal/config"
24 "github.com/charmbracelet/crush/internal/config"
24 "github.com/charmbracelet/crush/internal/db"
25 "github.com/charmbracelet/crush/internal/db"
@@ -68,8 +69,9 @@ type App struct {
68 tuiWG *sync.WaitGroup
69 tuiWG *sync.WaitGroup
69
70
70 // global context and cleanup functions
71 // global context and cleanup functions
71− globalCtx context.Context
72+ globalCtx context.Context
72− cleanupFuncs []func(context.Context) error
73+ cleanupFuncs []func(context.Context) error
74+ agentNotifications *pubsub.Broker[notify.Notification]
73}
75}
74
76
75// New initializes a new application instance.
77// New initializes a new application instance.
@@ -96,9 +98,10 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
96
98
97 config: cfg,
99 config: cfg,
98
100
99− events: make(chan tea.Msg, 100),
101+ events: make(chan tea.Msg, 100),
100− serviceEventsWG: &sync.WaitGroup{},
102+ serviceEventsWG: &sync.WaitGroup{},
101− tuiWG: &sync.WaitGroup{},
103+ tuiWG: &sync.WaitGroup{},
104+ agentNotifications: pubsub.NewBroker[notify.Notification](),
102 }
105 }
103
106
104 app.setupEvents()
107 app.setupEvents()
@@ -112,7 +115,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
112 app.cleanupFuncs = append(
115 app.cleanupFuncs = append(
113 app.cleanupFuncs,
116 app.cleanupFuncs,
114 func(context.Context) error { return conn.Close() },
117 func(context.Context) error { return conn.Close() },
115− mcp.Close,
118+ func(ctx context.Context) error { return mcp.Close(ctx) },
116 )
119 )
117
120
118 // TODO: remove the concept of agent config, most likely.
121 // TODO: remove the concept of agent config, most likely.
@@ -143,6 +146,11 @@ func (app *App) Config() *config.Config {
143 return app.config
146 return app.config
144}
147}
145
148
149+// AgentNotifications returns the broker for agent notification events.
150+func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] {
151+ return app.agentNotifications
152+}
153+
146// RunNonInteractive runs the application in non-interactive mode with the
154// RunNonInteractive runs the application in non-interactive mode with the
147// given prompt, printing to stdout.
155// given prompt, printing to stdout.
148func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
156func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
@@ -414,6 +422,7 @@ func (app *App) setupEvents() {
414 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
422 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
415 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
423 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
416 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
424 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
425+ setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events)
417 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
426 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
418 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
427 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
419 cleanupFunc := func(context.Context) error {
428 cleanupFunc := func(context.Context) error {
@@ -486,6 +495,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
486 app.History,
495 app.History,
487 app.FileTracker,
496 app.FileTracker,
488 app.LSPManager,
497 app.LSPManager,
498+ app.agentNotifications,
489 )
499 )
490 if err != nil {
500 if err != nil {
491 slog.Error("Failed to create coder agent", "err", err)
501 slog.Error("Failed to create coder agent", "err", err)
internal/config/config.go+1
@@ -261,6 +261,7 @@ type Options struct {
261 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"`
261 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"`
262 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
262 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
263 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
263 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
264+ DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
264}
265}
265
266
266type MCPs map[string]MCPConfig
267type MCPs map[string]MCPConfig
internal/permission/permission.go+6−6
@@ -134,6 +134,12 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
134 return true, nil
134 return true, nil
135 }
135 }
136
136
137+ // Check if the tool/action combination is in the allowlist
138+ commandKey := opts.ToolName + ":" + opts.Action
139+ if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
140+ return true, nil
141+ }
142+
137 // tell the UI that a permission was requested
143 // tell the UI that a permission was requested
138 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
144 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
139 ToolCallID: opts.ToolCallID,
145 ToolCallID: opts.ToolCallID,
@@ -141,12 +147,6 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
141 s.requestMu.Lock()
147 s.requestMu.Lock()
142 defer s.requestMu.Unlock()
148 defer s.requestMu.Unlock()
143
149
144− // Check if the tool/action combination is in the allowlist
145− commandKey := opts.ToolName + ":" + opts.Action
146− if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
147− return true, nil
148− }
149−
150 s.autoApproveSessionsMu.RLock()
150 s.autoApproveSessionsMu.RLock()
151 autoApprove := s.autoApproveSessions[opts.SessionID]
151 autoApprove := s.autoApproveSessions[opts.SessionID]
152 s.autoApproveSessionsMu.RUnlock()
152 s.autoApproveSessionsMu.RUnlock()
internal/ui/common/capabilities.go+2−2
@@ -58,7 +58,7 @@ func (c *Capabilities) Update(msg any) {
58 }
58 }
59 case tea.TerminalVersionMsg:
59 case tea.TerminalVersionMsg:
60 c.TerminalVersion = m.Name
60 c.TerminalVersion = m.Name
61− case uv.ModeReportEvent:
61+ case tea.ModeReportMsg:
62 switch m.Mode {
62 switch m.Mode {
63 case ansi.ModeFocusEvent:
63 case ansi.ModeFocusEvent:
64 c.ReportFocusEvents = modeSupported(m.Value)
64 c.ReportFocusEvents = modeSupported(m.Value)
@@ -77,7 +77,7 @@ func QueryCmd(env uv.Environ) tea.Cmd {
77 shouldQueryFor := shouldQueryCapabilities(env)
77 shouldQueryFor := shouldQueryCapabilities(env)
78 if shouldQueryFor {
78 if shouldQueryFor {
79 sb.WriteString(ansi.RequestNameVersion)
79 sb.WriteString(ansi.RequestNameVersion)
80− // sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications.
80+ sb.WriteString(ansi.RequestModeFocusEvent)
81 sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
81 sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
82 kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
82 kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
83 if _, isTmux := env.LookupEnv("TMUX"); isTmux {
83 if _, isTmux := env.LookupEnv("TMUX"); isTmux {
internal/ui/model/ui.go+77−11
@@ -25,6 +25,7 @@ import (
25 tea "charm.land/bubbletea/v2"
25 tea "charm.land/bubbletea/v2"
26 "charm.land/catwalk/pkg/catwalk"
26 "charm.land/catwalk/pkg/catwalk"
27 "charm.land/lipgloss/v2"
27 "charm.land/lipgloss/v2"
28+ "github.com/charmbracelet/crush/internal/agent/notify"
28 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
29 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
29 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
30 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
30 "github.com/charmbracelet/crush/internal/app"
31 "github.com/charmbracelet/crush/internal/app"
@@ -45,6 +46,7 @@ import (
45 "github.com/charmbracelet/crush/internal/ui/dialog"
46 "github.com/charmbracelet/crush/internal/ui/dialog"
46 fimage "github.com/charmbracelet/crush/internal/ui/image"
47 fimage "github.com/charmbracelet/crush/internal/ui/image"
47 "github.com/charmbracelet/crush/internal/ui/logo"
48 "github.com/charmbracelet/crush/internal/ui/logo"
49+ "github.com/charmbracelet/crush/internal/ui/notification"
48 "github.com/charmbracelet/crush/internal/ui/styles"
50 "github.com/charmbracelet/crush/internal/ui/styles"
49 "github.com/charmbracelet/crush/internal/ui/util"
51 "github.com/charmbracelet/crush/internal/ui/util"
50 "github.com/charmbracelet/crush/internal/version"
52 "github.com/charmbracelet/crush/internal/version"
@@ -201,6 +203,9 @@ type UI struct {
201 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
203 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
202 sidebarLogo string
204 sidebarLogo string
203
205
206+ // Notification state
207+ notifyBackend notification.Backend
208+ notifyWindowFocused bool
204 // custom commands & mcp commands
209 // custom commands & mcp commands
205 customCommands []commands.CustomCommand
210 customCommands []commands.CustomCommand
206 mcpPrompts []commands.MCPPrompt
211 mcpPrompts []commands.MCPPrompt
@@ -280,17 +285,19 @@ func New(com *common.Common) *UI {
280 header := newHeader(com)
285 header := newHeader(com)
281
286
282 ui := &UI{
287 ui := &UI{
283− com: com,
288+ com: com,
284− dialog: dialog.NewOverlay(),
289+ dialog: dialog.NewOverlay(),
285− keyMap: keyMap,
290+ keyMap: keyMap,
286− textarea: ta,
291+ textarea: ta,
287− chat: ch,
292+ chat: ch,
288− header: header,
293+ header: header,
289− completions: comp,
294+ completions: comp,
290− attachments: attachments,
295+ attachments: attachments,
291− todoSpinner: todoSpinner,
296+ todoSpinner: todoSpinner,
292− lspStates: make(map[string]app.LSPClientInfo),
297+ lspStates: make(map[string]app.LSPClientInfo),
293− mcpStates: make(map[string]mcp.ClientInfo),
298+ mcpStates: make(map[string]mcp.ClientInfo),
299+ notifyBackend: notification.NoopBackend{},
300+ notifyWindowFocused: true,
294 }
301 }
295
302
296 status := NewStatus(com, ui)
303 status := NewStatus(com, ui)
@@ -342,6 +349,32 @@ func (m *UI) Init() tea.Cmd {
342 return tea.Batch(cmds...)
349 return tea.Batch(cmds...)
343}
350}
344
351
352+// sendNotification returns a command that sends a notification if allowed by policy.
353+func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
354+ if !m.shouldSendNotification() {
355+ return nil
356+ }
357+
358+ backend := m.notifyBackend
359+ return func() tea.Msg {
360+ if err := backend.Send(n); err != nil {
361+ slog.Error("Failed to send notification", "error", err)
362+ }
363+ return nil
364+ }
365+}
366+
367+// shouldSendNotification returns true if notifications should be sent based on
368+// current state. Focus reporting must be supported, window must not focused,
369+// and notifications must not be disabled in config.
370+func (m *UI) shouldSendNotification() bool {
371+ cfg := m.com.Config()
372+ if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
373+ return false
374+ }
375+ return m.caps.ReportFocusEvents && !m.notifyWindowFocused
376+}
377+
345// setState changes the UI state and focus.
378// setState changes the UI state and focus.
346func (m *UI) setState(state uiState, focus uiFocusState) {
379func (m *UI) setState(state uiState, focus uiFocusState) {
347 if state == uiLanding {
380 if state == uiLanding {
@@ -397,6 +430,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
397 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
430 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
398 }
431 }
399 cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
432 cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
433+ case tea.ModeReportMsg:
434+ if m.caps.ReportFocusEvents {
435+ m.notifyBackend = notification.NewNativeBackend(notification.Icon)
436+ }
437+ case tea.FocusMsg:
438+ m.notifyWindowFocused = true
439+ case tea.BlurMsg:
440+ m.notifyWindowFocused = false
441+ case pubsub.Event[notify.Notification]:
442+ if cmd := m.handleAgentNotification(msg.Payload); cmd != nil {
443+ cmds = append(cmds, cmd)
444+ }
400 case loadSessionMsg:
445 case loadSessionMsg:
401 if m.forceCompactMode {
446 if m.forceCompactMode {
402 m.isCompact = true
447 m.isCompact = true
@@ -542,6 +587,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
542 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
587 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
543 cmds = append(cmds, cmd)
588 cmds = append(cmds, cmd)
544 }
589 }
590+ if cmd := m.sendNotification(notification.Notification{
591+ Title: "Crush is waiting...",
592+ Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
593+ }); cmd != nil {
594+ cmds = append(cmds, cmd)
595+ }
545 case pubsub.Event[permission.PermissionNotification]:
596 case pubsub.Event[permission.PermissionNotification]:
546 m.handlePermissionNotification(msg.Payload)
597 m.handlePermissionNotification(msg.Payload)
547 case cancelTimerExpiredMsg:
598 case cancelTimerExpiredMsg:
@@ -1964,6 +2015,7 @@ func (m *UI) View() tea.View {
1964 v.BackgroundColor = m.com.Styles.Background
2015 v.BackgroundColor = m.com.Styles.Background
1965 }
2016 }
1966 v.MouseMode = tea.MouseModeCellMotion
2017 v.MouseMode = tea.MouseModeCellMotion
2018+ v.ReportFocus = m.caps.ReportFocusEvents
1967 v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
2019 v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
1968
2020
1969 canvas := uv.NewScreenBuffer(m.width, m.height)
2021 canvas := uv.NewScreenBuffer(m.width, m.height)
@@ -2980,6 +3032,20 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
2980 }
3032 }
2981}
3033}
2982
3034
3035+// handleAgentNotification translates domain agent events into desktop
3036+// notifications using the UI notification backend.
3037+func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3038+ switch n.Type {
3039+ case notify.TypeAgentFinished:
3040+ return m.sendNotification(notification.Notification{
3041+ Title: "Crush is waiting...",
3042+ Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3043+ })
3044+ default:
3045+ return nil
3046+ }
3047+}
3048+
2983// newSession clears the current session state and prepares for a new session.
3049// newSession clears the current session state and prepares for a new session.
2984// The actual session creation happens when the user sends their first message.
3050// The actual session creation happens when the user sends their first message.
2985// Returns a command to reload prompt history.
3051// Returns a command to reload prompt history.
internal/ui/notification/crush-icon-solo.pngnew file

Binary file not shown.

internal/ui/notification/crush-icon.pngnew file

Binary file not shown.

internal/ui/notification/icon_darwin.gonew file+7
@@ -0,0 +1,7 @@
1+//go:build darwin
2+
3+package notification
4+
5+// Icon is currently empty on darwin because platform icon support is broken. Do
6+// use the icon for OSC notifications, just not native.
7+var Icon any = ""
internal/ui/notification/icon_other.gonew file+13
@@ -0,0 +1,13 @@
1+//go:build !darwin
2+
3+package notification
4+
5+import (
6+ _ "embed"
7+)
8+
9+//go:embed crush-icon-solo.png
10+var icon []byte
11+
12+// Icon contains the embedded PNG icon data for desktop notifications.
13+var Icon any = icon
internal/ui/notification/native.gonew file+49
@@ -0,0 +1,49 @@
1+package notification
2+
3+import (
4+ "log/slog"
5+
6+ "github.com/gen2brain/beeep"
7+)
8+
9+// NativeBackend sends desktop notifications using the native OS notification
10+// system via beeep.
11+type NativeBackend struct {
12+ // icon is the notification icon data (platform-specific).
13+ icon any
14+ // notifyFunc is the function used to send notifications (swappable for testing).
15+ notifyFunc func(title, message string, icon any) error
16+}
17+
18+// NewNativeBackend creates a new native notification backend.
19+func NewNativeBackend(icon any) *NativeBackend {
20+ beeep.AppName = "Crush"
21+ return &NativeBackend{
22+ icon: icon,
23+ notifyFunc: beeep.Notify,
24+ }
25+}
26+
27+// Send sends a desktop notification using the native OS notification system.
28+func (b *NativeBackend) Send(n Notification) error {
29+ slog.Debug("Sending native notification", "title", n.Title, "message", n.Message)
30+
31+ err := b.notifyFunc(n.Title, n.Message, b.icon)
32+ if err != nil {
33+ slog.Error("Failed to send notification", "error", err)
34+ } else {
35+ slog.Debug("Notification sent successfully")
36+ }
37+
38+ return err
39+}
40+
41+// SetNotifyFunc allows replacing the notification function for testing.
42+func (b *NativeBackend) SetNotifyFunc(fn func(title, message string, icon any) error) {
43+ b.notifyFunc = fn
44+}
45+
46+// ResetNotifyFunc resets the notification function to the default.
47+func (b *NativeBackend) ResetNotifyFunc() {
48+ b.notifyFunc = beeep.Notify
49+}
internal/ui/notification/noop.gonew file+10
@@ -0,0 +1,10 @@
1+package notification
2+
3+// NoopBackend is a no-op notification backend that does nothing.
4+// This is the default backend used when notifications are not supported.
5+type NoopBackend struct{}
6+
7+// Send does nothing and returns nil.
8+func (NoopBackend) Send(_ Notification) error {
9+ return nil
10+}
internal/ui/notification/notification.gonew file+15
@@ -0,0 +1,15 @@
1+// Package notification provides desktop notification support for the UI.
2+package notification
3+
4+// Notification represents a desktop notification request.
5+type Notification struct {
6+ Title string
7+ Message string
8+}
9+
10+// Backend defines the interface for sending desktop notifications.
11+// Implementations are pure transport - policy decisions (config, focus state)
12+// are handled by the caller.
13+type Backend interface {
14+ Send(n Notification) error
15+}
internal/ui/notification/notification_test.gonew file+43
@@ -0,0 +1,43 @@
1+package notification_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/ui/notification"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNoopBackend_Send(t *testing.T) {
11+ t.Parallel()
12+
13+ backend := notification.NoopBackend{}
14+ err := backend.Send(notification.Notification{
15+ Title: "Test Title",
16+ Message: "Test Message",
17+ })
18+ require.NoError(t, err)
19+}
20+
21+func TestNativeBackend_Send(t *testing.T) {
22+ t.Parallel()
23+
24+ backend := notification.NewNativeBackend(nil)
25+
26+ var capturedTitle, capturedMessage string
27+ var capturedIcon any
28+ backend.SetNotifyFunc(func(title, message string, icon any) error {
29+ capturedTitle = title
30+ capturedMessage = message
31+ capturedIcon = icon
32+ return nil
33+ })
34+
35+ err := backend.Send(notification.Notification{
36+ Title: "Hello",
37+ Message: "World",
38+ })
39+ require.NoError(t, err)
40+ require.Equal(t, "Hello", capturedTitle)
41+ require.Equal(t, "World", capturedMessage)
42+ require.Nil(t, capturedIcon)
43+}
schema.json+5
@@ -451,6 +451,11 @@
451 "type": "boolean",
451 "type": "boolean",
452 "description": "Show indeterminate progress updates during long operations",
452 "description": "Show indeterminate progress updates during long operations",
453 "default": true
453 "default": true
454+ },
455+ "disable_notifications": {
456+ "type": "boolean",
457+ "description": "Disable desktop notifications",
458+ "default": false
454 }
459 }
455 },
460 },
456 "additionalProperties": false,
461 "additionalProperties": false,