Skip to content

Commit 6047dab

Browse files
authored
Merge pull request #1870 from Mostamhd/feat/sound-notifications
feat: add sound notifications for task completion and errors
2 parents c45c010 + 4e21784 commit 6047dab

File tree

4 files changed

+125
-0
lines changed

4 files changed

+125
-0
lines changed

pkg/sound/sound.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Package sound provides cross-platform sound notification support.
2+
// It plays system sounds asynchronously to notify users of task completion or failure.
3+
package sound
4+
5+
import (
6+
"log/slog"
7+
"os/exec"
8+
"runtime"
9+
)
10+
11+
// Event represents the type of sound to play.
12+
type Event int
13+
14+
const (
15+
// Success is played when a task completes successfully.
16+
Success Event = iota
17+
// Failure is played when a task fails.
18+
Failure
19+
)
20+
21+
// Play plays a notification sound for the given event in the background.
22+
// It is non-blocking and safe to call from any goroutine.
23+
// If the sound cannot be played, the error is logged and silently ignored.
24+
func Play(event Event) {
25+
go func() {
26+
if err := playSound(event); err != nil {
27+
slog.Debug("Failed to play sound", "event", event, "error", err)
28+
}
29+
}()
30+
}
31+
32+
func playSound(event Event) error {
33+
switch runtime.GOOS {
34+
case "darwin":
35+
return playDarwin(event)
36+
case "linux":
37+
return playLinux(event)
38+
case "windows":
39+
return playWindows(event)
40+
default:
41+
return nil
42+
}
43+
}
44+
45+
func playDarwin(event Event) error {
46+
// Use macOS built-in system sounds via afplay
47+
var soundFile string
48+
switch event {
49+
case Success:
50+
soundFile = "/System/Library/Sounds/Glass.aiff"
51+
case Failure:
52+
soundFile = "/System/Library/Sounds/Basso.aiff"
53+
}
54+
return exec.Command("afplay", soundFile).Run()
55+
}
56+
57+
func playLinux(event Event) error {
58+
// Try paplay (PulseAudio) first, then fall back to terminal bell
59+
var soundFile string
60+
switch event {
61+
case Success:
62+
soundFile = "/usr/share/sounds/freedesktop/stereo/complete.oga"
63+
case Failure:
64+
soundFile = "/usr/share/sounds/freedesktop/stereo/dialog-error.oga"
65+
}
66+
67+
if path, err := exec.LookPath("paplay"); err == nil {
68+
return exec.Command(path, soundFile).Run()
69+
}
70+
71+
// Fallback: terminal bell via printf
72+
return exec.Command("printf", `\a`).Run()
73+
}
74+
75+
func playWindows(event Event) error {
76+
// Use PowerShell to play system sounds
77+
var script string
78+
switch event {
79+
case Success:
80+
script = `[System.Media.SystemSounds]::Asterisk.Play()`
81+
case Failure:
82+
script = `[System.Media.SystemSounds]::Hand.Play()`
83+
}
84+
return exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).Run()
85+
}

pkg/tui/page/chat/chat.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"path/filepath"
99
goruntime "runtime"
1010
"strings"
11+
"time"
1112

1213
"charm.land/bubbles/v2/help"
1314
"charm.land/bubbles/v2/key"
@@ -144,6 +145,7 @@ type chatPage struct {
144145
msgCancel context.CancelFunc
145146
streamCancelled bool
146147
streamDepth int // nesting depth of active streams (incremented on StreamStarted, decremented on StreamStopped)
148+
streamStartTime time.Time
147149

148150
// Track whether we've received content from an assistant response
149151
// Used by --exit-after-response to ensure we don't exit before receiving content

pkg/tui/page/chat/runtime_events.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import (
88
tea "charm.land/bubbletea/v2"
99

1010
"github.com/docker/docker-agent/pkg/runtime"
11+
"github.com/docker/docker-agent/pkg/sound"
1112
"github.com/docker/docker-agent/pkg/tui/components/notification"
1213
"github.com/docker/docker-agent/pkg/tui/components/sidebar"
1314
"github.com/docker/docker-agent/pkg/tui/core"
1415
"github.com/docker/docker-agent/pkg/tui/dialog"
1516
msgtypes "github.com/docker/docker-agent/pkg/tui/messages"
1617
"github.com/docker/docker-agent/pkg/tui/types"
18+
"github.com/docker/docker-agent/pkg/userconfig"
1719
)
1820

1921
// Runtime Event Handling
@@ -51,6 +53,9 @@ func (p *chatPage) handleRuntimeEvent(msg tea.Msg) (bool, tea.Cmd) {
5153
switch msg := msg.(type) {
5254
// ===== Error and Warning Events =====
5355
case *runtime.ErrorEvent:
56+
if userconfig.Get().GetSound() {
57+
sound.Play(sound.Failure)
58+
}
5459
return true, p.messages.AddErrorMessage(msg.Error)
5560

5661
case *runtime.WarningEvent:
@@ -191,6 +196,7 @@ func (p *chatPage) handleStreamStarted(msg *runtime.StreamStartedEvent) tea.Cmd
191196
slog.Debug("handleStreamStarted called", "agent", msg.AgentName, "session_id", msg.SessionID)
192197
p.streamCancelled = false
193198
p.streamDepth++
199+
p.streamStartTime = time.Now()
194200
spinnerCmd := p.setWorking(true)
195201
pendingCmd := p.setPendingResponse(true)
196202
sidebarCmd := p.forwardToSidebar(msg)
@@ -239,6 +245,13 @@ func (p *chatPage) handleStreamStopped(msg *runtime.StreamStoppedEvent) tea.Cmd
239245
}
240246

241247
// Outermost stream stopped — fully clean up.
248+
if userconfig.Get().GetSound() {
249+
duration := time.Since(p.streamStartTime)
250+
threshold := time.Duration(userconfig.Get().GetSoundThreshold()) * time.Second
251+
if duration >= threshold {
252+
sound.Play(sound.Success)
253+
}
254+
}
242255
p.msgCancel = nil
243256
p.streamCancelled = false
244257
spinnerCmd := p.setWorking(false)

pkg/userconfig/userconfig.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,20 @@ type Settings struct {
5555
// RestoreTabs restores previously open tabs when launching the TUI.
5656
// Defaults to false when not set (user must explicitly opt-in).
5757
RestoreTabs *bool `yaml:"restore_tabs,omitempty"`
58+
// Sound enables playing notification sounds on task success or failure.
59+
// Defaults to false when not set (user must explicitly opt-in).
60+
Sound *bool `yaml:"sound,omitempty"`
61+
// SoundThreshold is the minimum duration in seconds a task must run
62+
// before a success sound is played. Defaults to 5 seconds.
63+
SoundThreshold int `yaml:"sound_threshold,omitempty"`
5864
}
5965

6066
// DefaultTabTitleMaxLength is the default maximum tab title length when not configured.
6167
const DefaultTabTitleMaxLength = 20
6268

69+
// DefaultSoundThreshold is the default duration threshold for sound notifications.
70+
const DefaultSoundThreshold = 10
71+
6372
// GetTabTitleMaxLength returns the configured tab title max length, falling back to the default.
6473
func (s *Settings) GetTabTitleMaxLength() int {
6574
if s == nil || s.TabTitleMaxLength <= 0 {
@@ -68,6 +77,22 @@ func (s *Settings) GetTabTitleMaxLength() int {
6877
return s.TabTitleMaxLength
6978
}
7079

80+
// GetSound returns whether sound notifications are enabled, defaulting to true.
81+
func (s *Settings) GetSound() bool {
82+
if s == nil || s.Sound == nil {
83+
return true
84+
}
85+
return *s.Sound
86+
}
87+
88+
// GetSoundThreshold returns the minimum duration for sound notifications, defaulting to 5s.
89+
func (s *Settings) GetSoundThreshold() int {
90+
if s == nil || s.SoundThreshold <= 0 {
91+
return DefaultSoundThreshold
92+
}
93+
return s.SoundThreshold
94+
}
95+
7196
// GetSplitDiffView returns whether split diff view is enabled, defaulting to true.
7297
func (s *Settings) GetSplitDiffView() bool {
7398
if s == nil || s.SplitDiffView == nil {

0 commit comments

Comments
 (0)