mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(desktop): add system notifications for turns
This commit is contained in:
1 parent
912bbd86ae
commit
98ee87101b
9 files changed
+131
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user