diff --git a/desktop/app.go b/desktop/app.go index 262069efd2..b771dfd16b 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -43,6 +43,7 @@ import ( "reasonix/internal/i18n" "reasonix/internal/mcpdiag" "reasonix/internal/memory" + "reasonix/internal/notify" "reasonix/internal/plugin" "reasonix/internal/pluginpkg" "reasonix/internal/provider" @@ -142,6 +143,9 @@ type App struct { metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics + notificationSenderOnce sync.Once + notificationSender notify.Sender + runtimeEvents asyncRuntimeEmitter // promptHistoryTape is a lazy, cursor-addressed view of prompt history. It diff --git a/desktop/notifications_test.go b/desktop/notifications_test.go new file mode 100644 index 0000000000..1a698f55ad --- /dev/null +++ b/desktop/notifications_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "testing" + + "reasonix/internal/config" + "reasonix/internal/event" + "reasonix/internal/notify" +) + +type desktopRecordSink struct { + events []event.Kind +} + +func (s *desktopRecordSink) Emit(e event.Event) { + s.events = append(s.events, e.Kind) +} + +type desktopRecordSender struct { + messages []notify.Message +} + +func (s *desktopRecordSender) Send(m notify.Message) error { + s.messages = append(s.messages, m) + return nil +} + +func TestDesktopControllerSinkSendsConfiguredSystemNotifications(t *testing.T) { + inner := &desktopRecordSink{} + sender := &desktopRecordSender{} + app := &App{notificationSender: sender} + sink := app.desktopControllerSink(inner, config.NotificationsConfig{ + Enabled: true, + TurnDone: true, + ApprovalRequest: true, + AskRequest: true, + }) + + sink.Emit(event.Event{Kind: event.TurnDone}) + + if len(inner.events) != 1 || inner.events[0] != event.TurnDone { + t.Fatalf("forwarded events = %+v, want [TurnDone]", inner.events) + } + if len(sender.messages) != 1 { + t.Fatalf("notifications = %d, want 1", len(sender.messages)) + } + if sender.messages[0].Body != "Turn finished" { + t.Fatalf("notification body = %q, want Turn finished", sender.messages[0].Body) + } +} + +func TestDesktopControllerSinkSkipsSystemNotificationsWhenDisabled(t *testing.T) { + inner := &desktopRecordSink{} + sender := &desktopRecordSender{} + app := &App{notificationSender: sender} + sink := app.desktopControllerSink(inner, config.NotificationsConfig{ + Enabled: false, + TurnDone: true, + }) + + sink.Emit(event.Event{Kind: event.TurnDone}) + + if len(inner.events) != 1 || inner.events[0] != event.TurnDone { + t.Fatalf("forwarded events = %+v, want [TurnDone]", inner.events) + } + if len(sender.messages) != 0 { + t.Fatalf("notifications = %d, want 0", len(sender.messages)) + } +} diff --git a/desktop/tabs.go b/desktop/tabs.go index 707df72d85..6dfd421cae 100644 --- a/desktop/tabs.go +++ b/desktop/tabs.go @@ -27,6 +27,7 @@ import ( "reasonix/internal/event" "reasonix/internal/eventwire" "reasonix/internal/fileutil" + "reasonix/internal/notify" "reasonix/internal/provider" "reasonix/internal/store" ) @@ -2148,6 +2149,29 @@ func (a *App) buildTabControllerWithLoadedSession(tab *WorkspaceTab, loadedSessi a.buildTabControllerWithContext(tab, loadedSession, a.bootContext(), 0, nil) } +func (a *App) desktopNotificationSender() notify.Sender { + if a == nil { + return notify.NewPlatformSender() + } + a.notificationSenderOnce.Do(func() { + if a.notificationSender == nil { + a.notificationSender = notify.NewPlatformSender() + } + }) + return a.notificationSender +} + +func (a *App) desktopControllerSink(inner event.Sink, cfg config.NotificationsConfig) event.Sink { + if !cfg.Enabled { + return inner + } + sender := a.desktopNotificationSender() + if sender == nil { + return inner + } + return notify.NewSink(inner, sender, cfg) +} + func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loadedTabSession, buildCtx context.Context, buildGeneration uint64, buildCancel context.CancelFunc) { defer a.recoverToPending("buildTabController") keepBuildContext := false @@ -2265,11 +2289,12 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa } tab.SharedHostKey = rootKey sharedHost := a.acquireSharedHost(rootKey) + sink := a.desktopControllerSink(tab.sink, cfg.Notifications) ctrl, err := boot.Build(buildCtx, boot.Options{ Model: model, RequireKey: false, - Sink: tab.sink, + Sink: sink, WorkspaceRoot: root, SessionDir: sessionDir, EffortOverride: cloneStringPtr(tab.effort), diff --git a/go.mod b/go.mod index 2fe8eb4878..8d4ef246a0 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.7 charm.land/lipgloss/v2 v2.0.4 + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 github.com/BurntSushi/toml v1.6.0 github.com/alecthomas/chroma/v2 v2.27.0 github.com/atotto/clipboard v0.1.4 @@ -37,6 +38,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/dlclark/regexp2/v2 v2.2.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/gorilla/websocket v1.5.0 // indirect diff --git a/go.sum b/go.sum index 3620ba1686..b8529b1a99 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -43,6 +45,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -110,6 +114,7 @@ golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= diff --git a/internal/config/config.go b/internal/config/config.go index b2917d88e7..76cfa3e65f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -103,7 +103,7 @@ type DesktopConfig struct { ExpandThinking bool `toml:"expand_thinking"` // true = show reasoning text expanded by default; false = collapsed } -// NotificationsConfig controls optional system notifications for CLI chat/run. +// NotificationsConfig controls optional system notifications for CLI and desktop turns. type NotificationsConfig struct { Enabled bool `toml:"enabled"` TurnDone bool `toml:"turn_done"` diff --git a/internal/config/render.go b/internal/config/render.go index 1364e9a0a0..02421a05fb 100644 --- a/internal/config/render.go +++ b/internal/config/render.go @@ -119,7 +119,7 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string { b.WriteString("\n") b.WriteString("[notifications]\n") - fmt.Fprintf(&b, "enabled = %v # system notifications for CLI chat/run; default off\n", c.Notifications.Enabled) + fmt.Fprintf(&b, "enabled = %v # system notifications for CLI and desktop turns; default off\n", c.Notifications.Enabled) fmt.Fprintf(&b, "turn_done = %v # notify when a turn finishes\n", c.Notifications.TurnDone) fmt.Fprintf(&b, "approval_request = %v # notify when a tool approval is waiting\n", c.Notifications.ApprovalRequest) fmt.Fprintf(&b, "ask_request = %v # notify when a question is waiting\n", c.Notifications.AskRequest) diff --git a/internal/notify/sender_windows.go b/internal/notify/sender_windows.go index da7e3aadee..5268d306a4 100644 --- a/internal/notify/sender_windows.go +++ b/internal/notify/sender_windows.go @@ -2,15 +2,34 @@ package notify -import "os/exec" +import ( + "os/exec" -// PlatformSender delivers notifications through the host OS. + "git.sr.ht/~jackmordaunt/go-toast/v2" +) + +// PlatformSender delivers notifications through the Windows Toast API. type PlatformSender struct{} // NewPlatformSender returns the best-effort sender for the current platform. -func NewPlatformSender() PlatformSender { return PlatformSender{} } +func NewPlatformSender() PlatformSender { + _ = toast.SetAppData(toast.AppData{AppID: "Reasonix"}) + return PlatformSender{} +} func (PlatformSender) Send(m Message) error { + notification := toast.Notification{ + AppID: "Reasonix", + Title: m.Title, + Body: m.Body, + } + if err := notification.Push(); err == nil { + return nil + } + return sendPowerShellFallback(m) +} + +func sendPowerShellFallback(m Message) error { cmd := exec.Command("powershell", "-NoProfile", "-Command", ` if (Get-Command New-BurntToastNotification -ErrorAction SilentlyContinue) { New-BurntToastNotification -Text $args[0], $args[1] diff --git a/reasonix.example.toml b/reasonix.example.toml index bec794dba9..d4541d8d62 100644 --- a/reasonix.example.toml +++ b/reasonix.example.toml @@ -19,7 +19,7 @@ layout_style = "classic" # classic|workbench|creation; desktop layout style # theme_style = "graphite" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases [notifications] -enabled = false # system notifications for CLI chat/run; default off +enabled = false # system notifications for CLI and desktop turns; default off turn_done = true # notify when a turn finishes approval_request = true # notify when a tool approval is waiting ask_request = true # notify when a question is waiting