diff --git a/internal/config/config.go b/internal/config/config.go index c3895602740c1a9d4b0874bf925bf04b810c317b..498385816127468f686913ee5d31f043cdb27159 100644 --- a/internal/config/config.go +++ b/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"` } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 108501b9199aaa22ad46e787ce39235f06fc047e..33c2e7e028c247f4db8b1faca3c3d7a40c6c713c 100644 --- a/internal/ui/model/ui.go +++ b/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 } diff --git a/internal/ui/notification/bell.go b/internal/ui/notification/bell.go new file mode 100644 index 0000000000000000000000000000000000000000..fdea308cb2444905cbe9d1ed2f5a9f860c1bf877 --- /dev/null +++ b/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") +} diff --git a/internal/ui/notification/icon_darwin.go b/internal/ui/notification/icon_darwin.go index 27df25009be6bb849afc7b39b631fbbe3c61b6b3..497b235ba99739f0ce5ad6214a10278a24ffa30e 100644 --- a/internal/ui/notification/icon_darwin.go +++ b/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 diff --git a/internal/ui/notification/icon_other.go b/internal/ui/notification/icon_other.go index 27240ad93fc653c9e742a879e76914481e5f1d55..708b4009cb6aeaf42b69828828cdcebfdcef1c46 100644 --- a/internal/ui/notification/icon_other.go +++ b/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 diff --git a/internal/ui/notification/native.go b/internal/ui/notification/native.go index 9b497e1337ec69a6259bbee20e32a8775680e9c6..ad9afadd27ba6279fb0da539fd059749cbe2b3b7 100644 --- a/internal/ui/notification/native.go +++ b/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, diff --git a/internal/ui/notification/notification.go b/internal/ui/notification/notification.go index d8b17a815d29dc61871e35183d90b9cb694035ed..f7e2ea7227c7d54bb7d591aaf2217569660642b9 100644 --- a/internal/ui/notification/notification.go +++ b/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" diff --git a/internal/ui/notification/notification_test.go b/internal/ui/notification/notification_test.go index 1bb3fd400d8ccc167920d1cc73ec4c7023984620..2826cb663ef7c6176506b7286f375d7523ff6ce2 100644 --- a/internal/ui/notification/notification_test.go +++ b/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") +} diff --git a/internal/ui/notification/osc.go b/internal/ui/notification/osc.go index cf083214d681ae0df00107914e5ea949b6d4a776..973753becd8dccf11a347d08d14fa00147ee7a9b 100644 --- a/internal/ui/notification/osc.go +++ b/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))