Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions docs/pages/features/tui.html
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,16 @@ <h2>Tool Permissions</h2>
<div class="callout-title">💡 YOLO mode</div>
<p>Use <code>--yolo</code> or the <code>/yolo</code> command to auto-approve all tool calls. You can also toggle this mid-session. For aliases, set <code>--yolo</code> when creating the alias: <code>cagent alias add fast agentcatalog/coder --yolo</code>.</p>
</div>

<h2>Sound Notifications</h2>
<p>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.</p>

<ul>
<li><strong>Success Sound:</strong> Played when an agent successfully completes its turn, but <strong>only if the task took longer than the configured threshold</strong> (default: 10 seconds).</li>
<li><strong>Error Sound:</strong> Played immediately when a runtime error occurs.</li>
</ul>

<div class="callout callout-info">
<div class="callout-title">ℹ️ Platform Support</div>
<p>Notification sounds use native system commands: <code>afplay</code> on macOS, <code>paplay</code> on Linux, and PowerShell on Windows.</p>
</div>
8 changes: 8 additions & 0 deletions docs/pages/guides/tips.html
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,14 @@ <h3>User-Defined Default Model</h3>

<p>This model is used when you run <code>cagent run</code> without a config file.</p>

<h3>Sound Notifications</h3>
<p>Notification sounds are <strong>enabled by default</strong>. You can disable them or adjust the minimum duration threshold (default: 10s) by adding the following to your global user configuration in <code>~/.config/cagent/config.yaml</code>:</p>

<pre><code class="language-yaml">settings:
sound: false # disable all sounds
sound_threshold: 30 # only play success sound for tasks > 30s</code></pre>
</p>

<h3>GitHub PR Reviewer Example</h3>

<p>Use cagent as a GitHub Actions PR reviewer:</p>
Expand Down
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 @@ -9,6 +9,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
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/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
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 @@ -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()
Expand Down Expand Up @@ -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 {
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