diff --git a/pkg/sound/sound.go b/pkg/sound/sound.go new file mode 100644 index 000000000..7b5a23ffe --- /dev/null +++ b/pkg/sound/sound.go @@ -0,0 +1,85 @@ +// Package sound provides cross-platform sound notification support. +// It plays system sounds asynchronously to notify users of task completion or failure. +package sound + +import ( + "log/slog" + "os/exec" + "runtime" +) + +// Event represents the type of sound to play. +type Event int + +const ( + // Success is played when a task completes successfully. + Success Event = iota + // Failure is played when a task fails. + Failure +) + +// Play plays a notification sound for the given event in the background. +// It is non-blocking and safe to call from any goroutine. +// If the sound cannot be played, the error is logged and silently ignored. +func Play(event Event) { + go func() { + if err := playSound(event); err != nil { + slog.Debug("Failed to play sound", "event", event, "error", err) + } + }() +} + +func playSound(event Event) error { + switch runtime.GOOS { + case "darwin": + return playDarwin(event) + case "linux": + return playLinux(event) + case "windows": + return playWindows(event) + default: + return nil + } +} + +func playDarwin(event Event) error { + // Use macOS built-in system sounds via afplay + var soundFile string + switch event { + case Success: + soundFile = "/System/Library/Sounds/Glass.aiff" + case Failure: + soundFile = "/System/Library/Sounds/Basso.aiff" + } + return exec.Command("afplay", soundFile).Run() +} + +func playLinux(event Event) error { + // Try paplay (PulseAudio) first, then fall back to terminal bell + var soundFile string + switch event { + case Success: + soundFile = "/usr/share/sounds/freedesktop/stereo/complete.oga" + case Failure: + soundFile = "/usr/share/sounds/freedesktop/stereo/dialog-error.oga" + } + + if path, err := exec.LookPath("paplay"); err == nil { + return exec.Command(path, soundFile).Run() + } + + // Fallback: terminal bell via printf + return exec.Command("printf", `\a`).Run() +} + +func playWindows(event Event) error { + // Use PowerShell to play system sounds + var script string + switch event { + case Success: + script = `[System.Media.SystemSounds]::Asterisk.Play()` + case Failure: + script = `[System.Media.SystemSounds]::Hand.Play()` + } + return exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).Run() +} diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 75a730d01..3970c59fa 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -8,6 +8,7 @@ import ( "path/filepath" goruntime "runtime" "strings" + "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" @@ -144,6 +145,7 @@ type chatPage struct { msgCancel context.CancelFunc streamCancelled bool streamDepth int // nesting depth of active streams (incremented on StreamStarted, decremented on StreamStopped) + streamStartTime time.Time // Track whether we've received content from an assistant response // Used by --exit-after-response to ensure we don't exit before receiving content diff --git a/pkg/tui/page/chat/runtime_events.go b/pkg/tui/page/chat/runtime_events.go index ec3d99b44..f9787bcf1 100644 --- a/pkg/tui/page/chat/runtime_events.go +++ b/pkg/tui/page/chat/runtime_events.go @@ -8,12 +8,14 @@ import ( tea "charm.land/bubbletea/v2" "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/sound" "github.com/docker/docker-agent/pkg/tui/components/notification" "github.com/docker/docker-agent/pkg/tui/components/sidebar" "github.com/docker/docker-agent/pkg/tui/core" "github.com/docker/docker-agent/pkg/tui/dialog" msgtypes "github.com/docker/docker-agent/pkg/tui/messages" "github.com/docker/docker-agent/pkg/tui/types" + "github.com/docker/docker-agent/pkg/userconfig" ) // Runtime Event Handling @@ -51,6 +53,9 @@ func (p *chatPage) handleRuntimeEvent(msg tea.Msg) (bool, tea.Cmd) { switch msg := msg.(type) { // ===== Error and Warning Events ===== case *runtime.ErrorEvent: + if userconfig.Get().GetSound() { + sound.Play(sound.Failure) + } return true, p.messages.AddErrorMessage(msg.Error) case *runtime.WarningEvent: @@ -191,6 +196,7 @@ func (p *chatPage) handleStreamStarted(msg *runtime.StreamStartedEvent) tea.Cmd slog.Debug("handleStreamStarted called", "agent", msg.AgentName, "session_id", msg.SessionID) p.streamCancelled = false p.streamDepth++ + p.streamStartTime = time.Now() spinnerCmd := p.setWorking(true) pendingCmd := p.setPendingResponse(true) sidebarCmd := p.forwardToSidebar(msg) @@ -239,6 +245,13 @@ func (p *chatPage) handleStreamStopped(msg *runtime.StreamStoppedEvent) tea.Cmd } // Outermost stream stopped — fully clean up. + if userconfig.Get().GetSound() { + duration := time.Since(p.streamStartTime) + threshold := time.Duration(userconfig.Get().GetSoundThreshold()) * time.Second + if duration >= threshold { + sound.Play(sound.Success) + } + } p.msgCancel = nil p.streamCancelled = false spinnerCmd := p.setWorking(false) diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go index 81ecead0e..167d23cf4 100644 --- a/pkg/userconfig/userconfig.go +++ b/pkg/userconfig/userconfig.go @@ -55,11 +55,20 @@ type Settings struct { // RestoreTabs restores previously open tabs when launching the TUI. // Defaults to false when not set (user must explicitly opt-in). RestoreTabs *bool `yaml:"restore_tabs,omitempty"` + // Sound enables playing notification sounds on task success or failure. + // Defaults to false when not set (user must explicitly opt-in). + Sound *bool `yaml:"sound,omitempty"` + // SoundThreshold is the minimum duration in seconds a task must run + // before a success sound is played. Defaults to 5 seconds. + SoundThreshold int `yaml:"sound_threshold,omitempty"` } // DefaultTabTitleMaxLength is the default maximum tab title length when not configured. const DefaultTabTitleMaxLength = 20 +// DefaultSoundThreshold is the default duration threshold for sound notifications. +const DefaultSoundThreshold = 10 + // GetTabTitleMaxLength returns the configured tab title max length, falling back to the default. func (s *Settings) GetTabTitleMaxLength() int { if s == nil || s.TabTitleMaxLength <= 0 { @@ -68,6 +77,22 @@ func (s *Settings) GetTabTitleMaxLength() int { return s.TabTitleMaxLength } +// GetSound returns whether sound notifications are enabled, defaulting to true. +func (s *Settings) GetSound() bool { + if s == nil || s.Sound == nil { + return true + } + return *s.Sound +} + +// GetSoundThreshold returns the minimum duration for sound notifications, defaulting to 5s. +func (s *Settings) GetSoundThreshold() int { + if s == nil || s.SoundThreshold <= 0 { + return DefaultSoundThreshold + } + return s.SoundThreshold +} + // GetSplitDiffView returns whether split diff view is enabled, defaulting to true. func (s *Settings) GetSplitDiffView() bool { if s == nil || s.SplitDiffView == nil {