feat(notifications): add configurable backend and bell support

Kieran Klukas created

Change summary

internal/config/config.go                     |  1 
internal/ui/model/ui.go                       | 56 +++++++++++++++++---
internal/ui/notification/bell.go              | 27 ++++++++++
internal/ui/notification/icon_darwin.go       | 10 ++-
internal/ui/notification/icon_other.go        |  5 -
internal/ui/notification/native.go            |  6 +-
internal/ui/notification/notification.go      | 22 ++++---
internal/ui/notification/notification_test.go | 31 ++++++++---
internal/ui/notification/osc.go               | 44 +++++++++-------
9 files changed, 145 insertions(+), 57 deletions(-)

Detailed changes

internal/config/config.go 🔗

@@ -279,6 +279,7 @@ type Options struct {
 	AutoLSP                   *bool        `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
 	Progress                  *bool        `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
 	DisableNotifications      bool         `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
+	NotificationStyle         string       `json:"notification_style,omitempty" jsonschema:"description=Notification style to use. Options: auto (default), native, osc, bell, disabled. Auto selects based on environment: native for local sessions, osc for SSH (with automatic OSC 99/777 detection).,enum=auto,enum=native,enum=osc,enum=bell,enum=disabled,default=auto"`
 	DisabledSkills            []string     `json:"disabled_skills,omitempty" jsonschema:"description=List of skill names to disable and hide from the agent,example=crush-config"`
 }
 

internal/ui/model/ui.go 🔗

@@ -13,6 +13,7 @@ import (
 	"os"
 	"path/filepath"
 	"regexp"
+	"runtime"
 	"slices"
 	"strconv"
 	"strings"
@@ -436,31 +437,64 @@ func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
 }
 
 // selectNotificationBackend chooses the appropriate notification backend based
-// on terminal capabilities and environment. This is a pure function that should
-// be called once during initialization or when capabilities change.
-func selectNotificationBackend(caps common.Capabilities) notification.Backend {
+// on terminal capabilities, environment, and user configuration. This is a pure
+// function that should be called once during initialization or when capabilities
+// change.
+func selectNotificationBackend(caps common.Capabilities, cfg *config.Config) notification.Backend {
+	// Check for explicit user preference first.
+	if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle != "" {
+		switch cfg.Options.NotificationStyle {
+		case "native":
+			slog.Debug("Using native backend (user preference)")
+			return notification.NewNativeBackend(notification.Icon)
+		case "osc":
+			slog.Debug("Using OSC backend (user preference)", "osc99_supported", caps.OSC99Notifications)
+			return notification.NewOSCBackend(notification.Icon, caps.OSC99Notifications)
+		case "bell":
+			slog.Debug("Using bell backend (user preference)")
+			return notification.NewBellBackend()
+		case "disabled":
+			slog.Debug("Notifications disabled (user preference)")
+			return notification.NoopBackend{}
+		case "auto":
+			// Fall through to auto-detection below.
+		default:
+			slog.Warn("Unknown notification style, using auto", "style", cfg.Options.NotificationStyle)
+		}
+	}
+
+	// Auto-detect based on environment and capabilities.
 	_, isSSH := caps.Env.LookupEnv("SSH_TTY")
 
 	// SSH sessions use terminal-based notifications (OSC 99 or 777).
 	if isSSH {
-		if caps.OSC99Notifications {
-			return notification.NewOSC99Backend(notification.Icon)
-		}
-		return notification.NewOSC777Backend()
+		slog.Debug("Selected OSCBackend for SSH session", "osc99_supported", caps.OSC99Notifications)
+		return notification.NewOSCBackend(notification.Icon, caps.OSC99Notifications)
 	}
 
-	// Local sessions use native OS notifications if focus events are supported.
+	// Local sessions: prefer OSC on macOS because the native backend (beeep)
+	// uses terminal-notifier or AppleScript, which is slow and doesn't display
+	// icons properly. OSC 99 provides a more polished experience with icon support.
+	if runtime.GOOS == "darwin" {
+		slog.Debug("Selected OSCBackend for local macOS session", "osc99_supported", caps.OSC99Notifications)
+		return notification.NewOSCBackend(notification.Icon, caps.OSC99Notifications)
+	}
+
+	// Non-macOS local sessions use native OS notifications if focus events are supported.
 	// Without focus events, we can't suppress notifications when focused, so
 	// we disable them entirely to avoid spamming the user.
 	if caps.ReportFocusEvents {
+		slog.Debug("Selected NativeBackend for local session")
 		return notification.NewNativeBackend(notification.Icon)
 	}
 
+	slog.Debug("Selected NoopBackend (focus events not supported)")
 	return notification.NoopBackend{}
 }
 
 func (m *UI) updateNotificationBackend() {
-	m.notifyBackend = selectNotificationBackend(m.caps)
+	cfg := m.com.Config()
+	m.notifyBackend = selectNotificationBackend(m.caps, cfg)
 }
 
 // shouldSendNotification returns true if notifications should be sent based on
@@ -471,6 +505,10 @@ func (m *UI) shouldSendNotification() bool {
 	if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
 		return false
 	}
+	// If the user explicitly set style to "disabled", skip sending.
+	if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle == "disabled" {
+		return false
+	}
 	return m.caps.ReportFocusEvents && !m.notifyWindowFocused
 }
 

internal/ui/notification/bell.go 🔗

@@ -0,0 +1,27 @@
+package notification
+
+import (
+	"log/slog"
+
+	tea "charm.land/bubbletea/v2"
+)
+
+// BellBackend sends notifications by triggering the terminal bell. This is the
+// most basic notification mechanism and works in virtually all terminals, but
+// provides no visual message — just an audible or visual alert depending on
+// terminal configuration.
+type BellBackend struct{}
+
+// NewBellBackend creates a new bell notification backend.
+func NewBellBackend() *BellBackend {
+	return &BellBackend{}
+}
+
+// Send returns a [tea.Cmd] that triggers the terminal bell character (\x07).
+// The terminal will emit an audible beep or visual flash based on user
+// configuration. No message text is displayed.
+func (b *BellBackend) Send(n Notification) tea.Cmd {
+	slog.Debug("Sending bell notification", "title", n.Title, "message", n.Message)
+
+	return tea.Raw("\x07")
+}

internal/ui/notification/icon_darwin.go 🔗

@@ -2,6 +2,10 @@
 
 package notification
 
-// Icon is currently empty on darwin because platform icon support is broken. Do
-// use the icon for OSC notifications, just not native.
-var Icon any = ""
+import _ "embed"
+
+// Icon is the PNG data for the Crush icon, used for OSC 99 notifications.
+// Native macOS notifications don't support custom icons via beeep, but OSC 99 does.
+//
+//go:embed crush-icon.png
+var Icon []byte

internal/ui/notification/icon_other.go 🔗

@@ -7,7 +7,4 @@ import (
 )
 
 //go:embed crush-icon-solo.png
-var icon []byte
-
-// Icon contains the embedded PNG icon data for desktop notifications.
-var Icon any = icon
+var Icon []byte

internal/ui/notification/native.go 🔗

@@ -10,14 +10,14 @@ import (
 // NativeBackend sends desktop notifications using the native OS notification
 // system via beeep.
 type NativeBackend struct {
-	// icon is the notification icon data (platform-specific).
-	icon any
+	// icon is the notification icon data (PNG bytes).
+	icon []byte
 	// notifyFunc is the function used to send notifications (swappable for testing).
 	notifyFunc func(title, message string, icon any) error
 }
 
 // NewNativeBackend creates a new native notification backend.
-func NewNativeBackend(icon any) *NativeBackend {
+func NewNativeBackend(icon []byte) *NativeBackend {
 	beeep.AppName = "Crush"
 	return &NativeBackend{
 		icon:       icon,

internal/ui/notification/notification.go 🔗

@@ -2,17 +2,19 @@
 //
 // This package supports multiple notification backends:
 //   - NativeBackend: Uses the native OS notification system (macOS, Windows, Linux)
-//   - OSC99Backend: Uses the OSC 99 Desktop Notification protocol, supported by
-//     modern terminals like kitty, wezterm, and ghostty. Supports rich notifications
-//     with title, body, icons, and actions.
-//   - OSC777Backend: Uses the OSC 777 urxvt notification extension, widely supported
-//     but less capable (title and body only). Used as a fallback for SSH sessions.
-//   - NoopBackend: A no-op backend that silently discards notifications.
+//   - OSCBackend: Uses OSC escape sequences with automatic protocol detection.
+//     Prefers OSC 99 (modern standard with rich notifications) if supported,
+//     falling back to OSC 777 (urxvt extension, widely supported). Used for SSH sessions.
+//   - BellBackend: Triggers the terminal bell character (\x07), causing an audible
+//     beep or visual flash. Works in virtually all terminals but provides no message text.
+//   - NoopBackend: A no-op backend that silently discards notifications. Used when
+//     notifications are disabled or no suitable backend is available.
 //
-// Backend selection is based on terminal capabilities and environment:
-//   - SSH sessions prefer OSC 99 if available, falling back to OSC 777
-//   - Local sessions use native OS notifications
-//   - If focus events are not supported, notifications are disabled (NoopBackend)
+// Backend selection is based on terminal capabilities, environment, and user config:
+//   - Users can explicitly set notification_style in crush.json (auto/native/osc/bell/disabled)
+//   - Auto mode: SSH sessions use OSC backend (auto-detects OSC 99 vs 777)
+//   - Auto mode: Local sessions use native OS notifications
+//   - If focus events are not supported in local sessions, notifications are disabled (NoopBackend)
 package notification
 
 import tea "charm.land/bubbletea/v2"

internal/ui/notification/notification_test.go 🔗

@@ -60,10 +60,10 @@ func extractRawString(t *testing.T, cmd tea.Cmd) string {
 	return s
 }
 
-func TestOSC99Backend_Send(t *testing.T) {
+func TestOSCBackend_Send_OSC99(t *testing.T) {
 	t.Parallel()
 
-	backend := notification.NewOSC99Backend(nil)
+	backend := notification.NewOSCBackend(nil, true)
 	s := extractRawString(t, backend.Send(notification.Notification{
 		Title:   "Crush is waiting...",
 		Message: "Agent's turn completed",
@@ -78,10 +78,10 @@ func TestOSC99Backend_Send(t *testing.T) {
 	require.NotContains(t, s, "\x1b]9;")
 }
 
-func TestOSC99Backend_Send_TitleOnly(t *testing.T) {
+func TestOSCBackend_Send_OSC99_TitleOnly(t *testing.T) {
 	t.Parallel()
 
-	backend := notification.NewOSC99Backend(nil)
+	backend := notification.NewOSCBackend(nil, true)
 	s := extractRawString(t, backend.Send(notification.Notification{
 		Title: "Crush is waiting...",
 	}))
@@ -92,11 +92,11 @@ func TestOSC99Backend_Send_TitleOnly(t *testing.T) {
 	require.NotContains(t, s, "\x1b]9;")
 }
 
-func TestOSC99Backend_Send_WithIcon(t *testing.T) {
+func TestOSCBackend_Send_OSC99_WithIcon(t *testing.T) {
 	t.Parallel()
 
 	iconData := []byte("fake-png-data")
-	backend := notification.NewOSC99Backend(iconData)
+	backend := notification.NewOSCBackend(iconData, true)
 	s := extractRawString(t, backend.Send(notification.Notification{
 		Title:   "Test",
 		Message: "With icon",
@@ -111,10 +111,10 @@ func TestOSC99Backend_Send_WithIcon(t *testing.T) {
 	require.NotContains(t, s, "\x1b]9;")
 }
 
-func TestOSC777Backend_Send(t *testing.T) {
+func TestOSCBackend_Send_OSC777(t *testing.T) {
 	t.Parallel()
 
-	backend := notification.NewOSC777Backend()
+	backend := notification.NewOSCBackend(nil, false)
 	s := extractRawString(t, backend.Send(notification.Notification{
 		Title:   "Test",
 		Message: "With body",
@@ -196,3 +196,18 @@ func TestOSC99QuerySequence(t *testing.T) {
 	require.Contains(t, seq, "p=?")
 	require.Contains(t, seq, "\x07")
 }
+
+func TestBellBackend_Send(t *testing.T) {
+	t.Parallel()
+
+	backend := notification.NewBellBackend()
+	s := extractRawString(t, backend.Send(notification.Notification{
+		Title:   "Test",
+		Message: "Ignored by bell",
+	}))
+
+	// Bell backend only sends the bell character.
+	require.Equal(t, "\x07", s)
+	require.NotContains(t, s, "Test")
+	require.NotContains(t, s, "Ignored")
+}

internal/ui/notification/osc.go 🔗

@@ -78,23 +78,37 @@ func OSC99QuerySequence() string {
 	return ansi.DesktopNotification("", "i="+osc99QueryID, "p=?")
 }
 
-// OSC99Backend sends desktop notifications using OSC 99.
-type OSC99Backend struct {
-	icon []byte
+// OSCBackend sends desktop notifications using OSC escape sequences. It
+// automatically selects the best available protocol: OSC 99 (modern standard)
+// if supported, falling back to OSC 777 (urxvt extension) otherwise.
+type OSCBackend struct {
+	icon       []byte
+	supports99 bool
 }
 
-// NewOSC99Backend creates a new OSC 99 notification backend.
-func NewOSC99Backend(icon any) *OSC99Backend {
-	b := &OSC99Backend{}
+// NewOSCBackend creates a new OSC notification backend with automatic protocol
+// detection. If supports99 is true, it uses OSC 99; otherwise it falls back to
+// OSC 777.
+func NewOSCBackend(icon any, supports99 bool) *OSCBackend {
+	b := &OSCBackend{
+		supports99: supports99,
+	}
 	if data, ok := icon.([]byte); ok && len(data) > 0 {
 		b.icon = data
 	}
 	return b
 }
 
-// Send returns a [tea.Raw] command that writes OSC 99 escape sequences to the
-// terminal.
-func (b *OSC99Backend) Send(n Notification) tea.Cmd {
+// Send returns a [tea.Cmd] that writes OSC escape sequences to the terminal.
+// Uses OSC 99 if supported, otherwise OSC 777.
+func (b *OSCBackend) Send(n Notification) tea.Cmd {
+	if b.supports99 {
+		return b.sendOSC99(n)
+	}
+	return b.sendOSC777(n)
+}
+
+func (b *OSCBackend) sendOSC99(n Notification) tea.Cmd {
 	slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
 
 	var sb strings.Builder
@@ -119,17 +133,7 @@ func (b *OSC99Backend) Send(n Notification) tea.Cmd {
 	return tea.Raw(sb.String())
 }
 
-// OSC777Backend sends desktop notifications using OSC 777.
-type OSC777Backend struct{}
-
-// NewOSC777Backend creates a new OSC 777 notification backend.
-func NewOSC777Backend() *OSC777Backend {
-	return &OSC777Backend{}
-}
-
-// Send returns a [tea.Raw] command that writes an OSC 777 escape sequence to
-// the terminal.
-func (b *OSC777Backend) Send(n Notification) tea.Cmd {
+func (b *OSCBackend) sendOSC777(n Notification) tea.Cmd {
 	slog.Debug("Sending OSC 777 notification", "title", n.Title, "message", n.Message)
 
 	return tea.Raw(ansi.URxvtExt("notify", n.Title, n.Message))