feat(desktop): add system notifications for turns

This commit is contained in:
SivanCola committed 2026-07-03 14:58:09 +08:00
1 parent 912bbd86ae
commit 98ee87101b
9 files changed
+131 -7

No files matched your search

+4
View File
@@ -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
+69
View File
@@ -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))
}
}
+26 -1
View File
@@ -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),
+2
View File
@@ -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
+5
View File
@@ -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=
+1 -1
View File
@@ -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"`
+1 -1
View File
@@ -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)
+22 -3
View File
@@ -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]
+1 -1
View File
@@ -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