diff --git a/docs/TODO.md b/docs/TODO.md index e8fb81262..c03291f5b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -79,6 +79,7 @@ - [x] Ctrl+L audio listening shortcut — *(Added)* - [x] Ctrl+X to clear queued messages — *(Added)* - [x] Permissions view dialog — *(Mentioned)* +- [x] Sound notifications configuration — *(Added)* - [x] Model picker / switching during session — *(Already documented)* - [ ] Branching sessions (edit previous messages) — Mentioned but could have more detail. - [ ] Double-click title to edit — Minor feature. diff --git a/docs/pages/features/tui.html b/docs/pages/features/tui.html index efcc62063..fee21630a 100644 --- a/docs/pages/features/tui.html +++ b/docs/pages/features/tui.html @@ -220,3 +220,16 @@
Use --yolo or the /yolo command to auto-approve all tool calls. You can also toggle this mid-session. For aliases, set --yolo when creating the alias: cagent alias add fast agentcatalog/coder --yolo.
cagent can play notification sounds to alert you when a task completes or fails. This is particularly useful for long-running tasks where you might have switched to another window.
+ +Notification sounds use native system commands: afplay on macOS, paplay on Linux, and PowerShell on Windows.
This model is used when you run cagent run without a config file.
Notification sounds are enabled by default. You can disable them or adjust the minimum duration threshold (default: 10s) by adding the following to your global user configuration in ~/.config/cagent/config.yaml:
settings:
+ sound: false # disable all sounds
+ sound_threshold: 30 # only play success sound for tasks > 30s
+
+
Use cagent as a GitHub Actions PR reviewer:
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 66f42ec03..e61671c4a 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -9,6 +9,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 + 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 e0600fb0a..dfb17d734 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/cagent/pkg/runtime" + "github.com/docker/cagent/pkg/sound" "github.com/docker/cagent/pkg/tui/components/notification" "github.com/docker/cagent/pkg/tui/components/sidebar" "github.com/docker/cagent/pkg/tui/core" "github.com/docker/cagent/pkg/tui/dialog" msgtypes "github.com/docker/cagent/pkg/tui/messages" "github.com/docker/cagent/pkg/tui/types" + "github.com/docker/cagent/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: @@ -184,6 +189,7 @@ func (p *chatPage) handleTokenUsage(msg *runtime.TokenUsageEvent) { func (p *chatPage) handleStreamStarted(msg *runtime.StreamStartedEvent) tea.Cmd { slog.Debug("handleStreamStarted called", "agent", msg.AgentName, "session_id", msg.SessionID) p.streamCancelled = false + p.streamStartTime = time.Now() spinnerCmd := p.setWorking(true) pendingCmd := p.setPendingResponse(true) p.startProgressBar() @@ -216,6 +222,13 @@ func (p *chatPage) handleStreamStopped(msg *runtime.StreamStoppedEvent) tea.Cmd "session_id", msg.SessionID, "should_exit", p.app.ShouldExitAfterFirstResponse(), "has_content", p.hasReceivedAssistantContent) + if userconfig.Get().GetSound() { + duration := time.Since(p.streamStartTime) + threshold := time.Duration(userconfig.Get().GetSoundThreshold()) * time.Second + if duration >= threshold { + sound.Play(sound.Success) + } + } spinnerCmd := p.setWorking(false) p.setPendingResponse(false) if p.msgCancel != nil { diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go index 6e8910942..b3c61b4e6 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 {