Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions pkg/sound/sound.go
Original file line number Diff line number Diff line change
@@ -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()
}
2 changes: 2 additions & 0 deletions pkg/tui/page/chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
goruntime "runtime"
"strings"
"time"

"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions pkg/tui/page/chat/runtime_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions pkg/userconfig/userconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down