diff --git a/go.mod b/go.mod index f8c69810e844780a76445b55036098c63757f71e..02a4398f89fe36338adeb58e6f7497f07029008b 100644 --- a/go.mod +++ b/go.mod @@ -65,6 +65,7 @@ require ( cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.8.0 // indirect + git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect github.com/RealAlexandreAI/json-repair v0.0.14 // indirect @@ -98,14 +99,18 @@ 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/gen2brain/beeep v0.11.1 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.3.0 // indirect github.com/google/s2a-go v0.1.9 // indirect @@ -115,6 +120,7 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/kaptinlin/go-i18n v0.2.0 // indirect github.com/kaptinlin/jsonpointer v0.4.6 // indirect github.com/kaptinlin/jsonschema v0.6.1 // indirect @@ -136,10 +142,13 @@ require ( github.com/ncruces/julianday v1.0.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sergeymakinen/go-bmp v1.0.0 // indirect + github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/tetratelabs/wazero v1.10.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect diff --git a/go.sum b/go.sum index 1458d7a78717609e015f21ea9d52b3b45a6df4dc..ec8e97d5390f096c2445ae18ff086997942a683a 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= +git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= +git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= @@ -142,11 +144,15 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= +github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gen2brain/beeep v0.11.1 h1:EbSIhrQZFDj1K2fzlMpAYlFOzV8YuNe721A58XcCTYI= +github.com/gen2brain/beeep v0.11.1/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -156,12 +162,16 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -192,6 +202,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= +github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= @@ -286,6 +298,10 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= +github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= +github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ= +github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= @@ -303,11 +319,18 @@ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/tetratelabs/wazero v1.10.0 h1:CXP3zneLDl6J4Zy8N/J+d5JsWKfrjE6GtvVK1fpnDlk= github.com/tetratelabs/wazero v1.10.0/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -408,6 +431,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index ec5bc19ba4efaf0cc15f46620711621a92dff2b9..9556fc31f9688b00d5b2799e8af07fa25c817d9e 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -31,6 +31,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/notification" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/stringext" @@ -485,6 +486,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy } wg.Wait() + // Send notification that agent has finished its turn. + sessionLock.Lock() + notifBody := fmt.Sprintf("Agent's turn completed in \"%s\"", currentSession.Title) + sessionLock.Unlock() + if err := notification.Send("Crush is waiting...", notifBody); err != nil { + // Log but don't fail on notification errors. + } + if shouldSummarize { a.activeRequests.Del(call.SessionID) if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil { diff --git a/internal/notification/crush-icon-solo.png b/internal/notification/crush-icon-solo.png new file mode 100644 index 0000000000000000000000000000000000000000..eed026660d0d5882c9b6e98912ee2afd9748f2a6 Binary files /dev/null and b/internal/notification/crush-icon-solo.png differ diff --git a/internal/notification/crush-icon.png b/internal/notification/crush-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..138a7ab6d246a0989cf9fca296e7c7df78523465 Binary files /dev/null and b/internal/notification/crush-icon.png differ diff --git a/internal/notification/notification.go b/internal/notification/notification.go new file mode 100644 index 0000000000000000000000000000000000000000..30a34b6f1500aead9b5f79bf5a076fbc9871892b --- /dev/null +++ b/internal/notification/notification.go @@ -0,0 +1,66 @@ +package notification + +import ( + "log/slog" + "sync" + + "github.com/gen2brain/beeep" +) + +var ( + isFocused = true + supportsFocus = false + focusStateLock sync.RWMutex +) + +// SetFocusSupport sets whether the terminal supports focus reporting. +func SetFocusSupport(supported bool) { + focusStateLock.Lock() + defer focusStateLock.Unlock() + supportsFocus = supported +} + +// SetFocused sets whether the terminal window is currently focused. +func SetFocused(focused bool) { + focusStateLock.Lock() + defer focusStateLock.Unlock() + isFocused = focused +} + +// IsFocused returns whether the terminal window is currently focused. +func IsFocused() bool { + focusStateLock.RLock() + defer focusStateLock.RUnlock() + return isFocused +} + +// Send sends a desktop notification with the given title and message. +// Notifications are only sent when the terminal window is not focused. +// On darwin (macOS), icons are omitted due to platform limitations. +func Send(title, message string) error { + focusStateLock.RLock() + focused := isFocused + supported := supportsFocus + focusStateLock.RUnlock() + + slog.Debug("notification.Send called", "title", title, "message", message, "focused", focused, "supported", supported) + + // Only skip notification if we know the window is focused. + // Send by default if focus reporting is not supported. + if supported && focused { + slog.Debug("skipping notification: window is focused") + return nil + } + + beeep.AppName = "Crush" + + err := beeep.Notify(title, message, notificationIcon) + + if err != nil { + slog.Error("failed to send notification", "error", err) + } else { + slog.Debug("notification sent successfully") + } + + return err +} diff --git a/internal/notification/notification_darwin.go b/internal/notification/notification_darwin.go new file mode 100644 index 0000000000000000000000000000000000000000..dcfa44e0b6cd943c5aee7d13c9f9c3f78c71b8fb --- /dev/null +++ b/internal/notification/notification_darwin.go @@ -0,0 +1,6 @@ +//go:build darwin + +package notification + +// notificationIcon is empty on darwin because icon support is broken. +var notificationIcon interface{} = "" diff --git a/internal/notification/notification_other.go b/internal/notification/notification_other.go new file mode 100644 index 0000000000000000000000000000000000000000..a2a1b39268ab62caccd8c8842d714bc43c5afc11 --- /dev/null +++ b/internal/notification/notification_other.go @@ -0,0 +1,13 @@ +//go:build !darwin + +package notification + +import ( + _ "embed" +) + +//go:embed crush-icon-solo.png +var icon []byte + +// notificationIcon contains the embedded PNG icon data for desktop notifications. +var notificationIcon interface{} = icon diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c53220094ef6af67f789b98ff177a09fa3dfd7b1..f64532a5bf50ed482cf9843750f94fb56cbe326f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/event" + "github.com/charmbracelet/crush/internal/notification" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat" @@ -34,6 +35,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/page/chat" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/x/ansi" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -84,6 +86,10 @@ type appModel struct { // QueryVersion instructs the TUI to query for the terminal version when it // starts. QueryVersion bool + + // Focus tracking for notifications. + supportsFocus bool + isFocused bool } // Init initializes the application model and returns initial commands. @@ -104,6 +110,9 @@ func (a appModel) Init() tea.Cmd { cmds = append(cmds, tea.RequestTerminalVersion) } + // Request focus event support from the terminal. + cmds = append(cmds, tea.Raw(ansi.RequestModeFocusEvent)) + return tea.Batch(cmds...) } @@ -114,6 +123,22 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.isConfigured = config.HasInitialDataConfig() switch msg := msg.(type) { + case tea.ModeReportMsg: + if msg.Mode == ansi.ModeFocusEvent && !msg.Value.IsNotRecognized() { + a.supportsFocus = true + a.isFocused = true + notification.SetFocusSupport(true) + notification.SetFocused(true) + } + return a, nil + case tea.FocusMsg: + a.isFocused = true + notification.SetFocused(true) + return a, nil + case tea.BlurMsg: + a.isFocused = false + notification.SetFocused(false) + return a, nil case tea.EnvMsg: // Is this Windows Terminal? if !a.sendProgressBar { @@ -304,6 +329,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, itemCmd case pubsub.Event[permission.PermissionRequest]: + // Send notification if window is not focused. + notifBody := fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName) + if err := notification.Send("Crush is waiting...", notifBody); err != nil { + // Log but don't fail on notification errors. + } return a, util.CmdHandler(dialogs.OpenDialogMsg{ Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{ DiffMode: config.Get().Options.TUI.DiffMode, @@ -580,6 +610,7 @@ func (a *appModel) View() tea.View { view.AltScreen = true view.MouseMode = tea.MouseModeCellMotion view.BackgroundColor = t.BgBase + view.ReportFocus = true if a.wWidth < 25 || a.wHeight < 15 { view.SetContent( lipgloss.NewCanvas(