on main help sign in
feat(notifications): add configurable backend and bell support
e7f5c68Kieran Klukas
internal/config/config.go+1
@@ -279,6 +279,7 @@ type Options struct {
279 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
279 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
280 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
280 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
281 DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
281 DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
282+ 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"`
282 DisabledSkills []string `json:"disabled_skills,omitempty" jsonschema:"description=List of skill names to disable and hide from the agent,example=crush-config"`
283 DisabledSkills []string `json:"disabled_skills,omitempty" jsonschema:"description=List of skill names to disable and hide from the agent,example=crush-config"`
283}
284}
284
285
internal/ui/model/ui.go+47−9
@@ -13,6 +13,7 @@ import (
13 "os"
13 "os"
14 "path/filepath"
14 "path/filepath"
15 "regexp"
15 "regexp"
16+ "runtime"
16 "slices"
17 "slices"
17 "strconv"
18 "strconv"
18 "strings"
19 "strings"
@@ -436,31 +437,64 @@ func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
436}
437}
437
438
438// selectNotificationBackend chooses the appropriate notification backend based
439// selectNotificationBackend chooses the appropriate notification backend based
439−// on terminal capabilities and environment. This is a pure function that should
440+// on terminal capabilities, environment, and user configuration. This is a pure
440−// be called once during initialization or when capabilities change.
441+// function that should be called once during initialization or when capabilities
442+// change.
441−func selectNotificationBackend(caps common.Capabilities) notification.Backend {
443+func selectNotificationBackend(caps common.Capabilities, cfg *config.Config) notification.Backend {
444+ // Check for explicit user preference first.
445+ if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle != "" {
446+ switch cfg.Options.NotificationStyle {
447+ case "native":
448+ slog.Debug("Using native backend (user preference)")
449+ return notification.NewNativeBackend(notification.Icon)
450+ case "osc":
451+ slog.Debug("Using OSC backend (user preference)", "osc99_supported", caps.OSC99Notifications)
452+ return notification.NewOSCBackend(notification.Icon, caps.OSC99Notifications)
453+ case "bell":
454+ slog.Debug("Using bell backend (user preference)")
455+ return notification.NewBellBackend()
456+ case "disabled":
457+ slog.Debug("Notifications disabled (user preference)")
458+ return notification.NoopBackend{}
459+ case "auto":
460+ // Fall through to auto-detection below.
461+ default:
462+ slog.Warn("Unknown notification style, using auto", "style", cfg.Options.NotificationStyle)
463+ }
464+ }
465+
466+ // Auto-detect based on environment and capabilities.
442 _, isSSH := caps.Env.LookupEnv("SSH_TTY")
467 _, isSSH := caps.Env.LookupEnv("SSH_TTY")
443
468
444 // SSH sessions use terminal-based notifications (OSC 99 or 777).
469 // SSH sessions use terminal-based notifications (OSC 99 or 777).
445 if isSSH {
470 if isSSH {
446− if caps.OSC99Notifications {
471+ slog.Debug("Selected OSCBackend for SSH session", "osc99_supported", caps.OSC99Notifications)
447− return notification.NewOSC99Backend(notification.Icon)
472+ return notification.NewOSCBackend(notification.Icon, caps.OSC99Notifications)
448− }
449− return notification.NewOSC777Backend()
450 }
473 }
451
474
475+ // Local sessions: prefer OSC on macOS because the native backend (beeep)
476+ // uses terminal-notifier or AppleScript, which is slow and doesn't display
477+ // icons properly. OSC 99 provides a more polished experience with icon support.
478+ if runtime.GOOS == "darwin" {
479+ slog.Debug("Selected OSCBackend for local macOS session", "osc99_supported", caps.OSC99Notifications)
480+ return notification.NewOSCBackend(notification.Icon, caps.OSC99Notifications)
481+ }
482+
452− // Local sessions use native OS notifications if focus events are supported.
483+ // Non-macOS local sessions use native OS notifications if focus events are supported.
453 // Without focus events, we can't suppress notifications when focused, so
484 // Without focus events, we can't suppress notifications when focused, so
454 // we disable them entirely to avoid spamming the user.
485 // we disable them entirely to avoid spamming the user.
455 if caps.ReportFocusEvents {
486 if caps.ReportFocusEvents {
487+ slog.Debug("Selected NativeBackend for local session")
456 return notification.NewNativeBackend(notification.Icon)
488 return notification.NewNativeBackend(notification.Icon)
457 }
489 }
458
490
491+ slog.Debug("Selected NoopBackend (focus events not supported)")
459 return notification.NoopBackend{}
492 return notification.NoopBackend{}
460}
493}
461
494
462func (m *UI) updateNotificationBackend() {
495func (m *UI) updateNotificationBackend() {
496+ cfg := m.com.Config()
463− m.notifyBackend = selectNotificationBackend(m.caps)
497+ m.notifyBackend = selectNotificationBackend(m.caps, cfg)
464}
498}
465
499
466// shouldSendNotification returns true if notifications should be sent based on
500// shouldSendNotification returns true if notifications should be sent based on
@@ -471,6 +505,10 @@ func (m *UI) shouldSendNotification() bool {
471 if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
505 if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
472 return false
506 return false
473 }
507 }
508+ // If the user explicitly set style to "disabled", skip sending.
509+ if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle == "disabled" {
510+ return false
511+ }
474 return m.caps.ReportFocusEvents && !m.notifyWindowFocused
512 return m.caps.ReportFocusEvents && !m.notifyWindowFocused
475}
513}
476
514
internal/ui/notification/bell.gonew file+27
@@ -0,0 +1,27 @@
1+package notification
2+
3+import (
4+ "log/slog"
5+
6+ tea "charm.land/bubbletea/v2"
7+)
8+
9+// BellBackend sends notifications by triggering the terminal bell. This is the
10+// most basic notification mechanism and works in virtually all terminals, but
11+// provides no visual message — just an audible or visual alert depending on
12+// terminal configuration.
13+type BellBackend struct{}
14+
15+// NewBellBackend creates a new bell notification backend.
16+func NewBellBackend() *BellBackend {
17+ return &BellBackend{}
18+}
19+
20+// Send returns a [tea.Cmd] that triggers the terminal bell character (\x07).
21+// The terminal will emit an audible beep or visual flash based on user
22+// configuration. No message text is displayed.
23+func (b *BellBackend) Send(n Notification) tea.Cmd {
24+ slog.Debug("Sending bell notification", "title", n.Title, "message", n.Message)
25+
26+ return tea.Raw("\x07")
27+}
internal/ui/notification/icon_darwin.go+7−3
@@ -2,6 +2,10 @@
2
2
3package notification
3package notification
4
4
5−// Icon is currently empty on darwin because platform icon support is broken. Do
5+import _ "embed"
6−// use the icon for OSC notifications, just not native.
6+
7+// Icon is the PNG data for the Crush icon, used for OSC 99 notifications.
8+// Native macOS notifications don't support custom icons via beeep, but OSC 99 does.
9+//
10+//go:embed crush-icon.png
7−var Icon any = ""
11+var Icon []byte
internal/ui/notification/icon_other.go+1−4
@@ -7,7 +7,4 @@ import (
7)
7)
8
8
9//go:embed crush-icon-solo.png
9//go:embed crush-icon-solo.png
10−var icon []byte
10+var Icon []byte
11−
12−// Icon contains the embedded PNG icon data for desktop notifications.
13−var Icon any = icon
internal/ui/notification/native.go+3−3
@@ -10,14 +10,14 @@ import (
10// NativeBackend sends desktop notifications using the native OS notification
10// NativeBackend sends desktop notifications using the native OS notification
11// system via beeep.
11// system via beeep.
12type NativeBackend struct {
12type NativeBackend struct {
13− // icon is the notification icon data (platform-specific).
13+ // icon is the notification icon data (PNG bytes).
14− icon any
14+ icon []byte
15 // notifyFunc is the function used to send notifications (swappable for testing).
15 // notifyFunc is the function used to send notifications (swappable for testing).
16 notifyFunc func(title, message string, icon any) error
16 notifyFunc func(title, message string, icon any) error
17}
17}
18
18
19// NewNativeBackend creates a new native notification backend.
19// NewNativeBackend creates a new native notification backend.
20−func NewNativeBackend(icon any) *NativeBackend {
20+func NewNativeBackend(icon []byte) *NativeBackend {
21 beeep.AppName = "Crush"
21 beeep.AppName = "Crush"
22 return &NativeBackend{
22 return &NativeBackend{
23 icon: icon,
23 icon: icon,
internal/ui/notification/notification.go+12−10
@@ -2,17 +2,19 @@
2//
2//
3// This package supports multiple notification backends:
3// This package supports multiple notification backends:
4// - NativeBackend: Uses the native OS notification system (macOS, Windows, Linux)
4// - NativeBackend: Uses the native OS notification system (macOS, Windows, Linux)
5−// - OSC99Backend: Uses the OSC 99 Desktop Notification protocol, supported by
5+// - OSCBackend: Uses OSC escape sequences with automatic protocol detection.
6−// modern terminals like kitty, wezterm, and ghostty. Supports rich notifications
6+// Prefers OSC 99 (modern standard with rich notifications) if supported,
7−// with title, body, icons, and actions.
8−// - OSC777Backend: Uses the OSC 777 urxvt notification extension, widely supported
7+// falling back to OSC 777 (urxvt extension, widely supported). Used for SSH sessions.
9−// but less capable (title and body only). Used as a fallback for SSH sessions.
8+// - BellBackend: Triggers the terminal bell character (\x07), causing an audible
9+// beep or visual flash. Works in virtually all terminals but provides no message text.
10−// - NoopBackend: A no-op backend that silently discards notifications.
10+// - NoopBackend: A no-op backend that silently discards notifications. Used when
11+// notifications are disabled or no suitable backend is available.
11//
12//
12−// Backend selection is based on terminal capabilities and environment:
13+// Backend selection is based on terminal capabilities, environment, and user config:
13−// - SSH sessions prefer OSC 99 if available, falling back to OSC 777
14+// - Users can explicitly set notification_style in crush.json (auto/native/osc/bell/disabled)
15+// - Auto mode: SSH sessions use OSC backend (auto-detects OSC 99 vs 777)
14−// - Local sessions use native OS notifications
16+// - Auto mode: Local sessions use native OS notifications
15−// - If focus events are not supported, notifications are disabled (NoopBackend)
17+// - If focus events are not supported in local sessions, notifications are disabled (NoopBackend)
16package notification
18package notification
17
19
18import tea "charm.land/bubbletea/v2"
20import tea "charm.land/bubbletea/v2"
internal/ui/notification/notification_test.go+23−8
@@ -60,10 +60,10 @@ func extractRawString(t *testing.T, cmd tea.Cmd) string {
60 return s
60 return s
61}
61}
62
62
63−func TestOSC99Backend_Send(t *testing.T) {
63+func TestOSCBackend_Send_OSC99(t *testing.T) {
64 t.Parallel()
64 t.Parallel()
65
65
66− backend := notification.NewOSC99Backend(nil)
66+ backend := notification.NewOSCBackend(nil, true)
67 s := extractRawString(t, backend.Send(notification.Notification{
67 s := extractRawString(t, backend.Send(notification.Notification{
68 Title: "Crush is waiting...",
68 Title: "Crush is waiting...",
69 Message: "Agent's turn completed",
69 Message: "Agent's turn completed",
@@ -78,10 +78,10 @@ func TestOSC99Backend_Send(t *testing.T) {
78 require.NotContains(t, s, "\x1b]9;")
78 require.NotContains(t, s, "\x1b]9;")
79}
79}
80
80
81−func TestOSC99Backend_Send_TitleOnly(t *testing.T) {
81+func TestOSCBackend_Send_OSC99_TitleOnly(t *testing.T) {
82 t.Parallel()
82 t.Parallel()
83
83
84− backend := notification.NewOSC99Backend(nil)
84+ backend := notification.NewOSCBackend(nil, true)
85 s := extractRawString(t, backend.Send(notification.Notification{
85 s := extractRawString(t, backend.Send(notification.Notification{
86 Title: "Crush is waiting...",
86 Title: "Crush is waiting...",
87 }))
87 }))
@@ -92,11 +92,11 @@ func TestOSC99Backend_Send_TitleOnly(t *testing.T) {
92 require.NotContains(t, s, "\x1b]9;")
92 require.NotContains(t, s, "\x1b]9;")
93}
93}
94
94
95−func TestOSC99Backend_Send_WithIcon(t *testing.T) {
95+func TestOSCBackend_Send_OSC99_WithIcon(t *testing.T) {
96 t.Parallel()
96 t.Parallel()
97
97
98 iconData := []byte("fake-png-data")
98 iconData := []byte("fake-png-data")
99− backend := notification.NewOSC99Backend(iconData)
99+ backend := notification.NewOSCBackend(iconData, true)
100 s := extractRawString(t, backend.Send(notification.Notification{
100 s := extractRawString(t, backend.Send(notification.Notification{
101 Title: "Test",
101 Title: "Test",
102 Message: "With icon",
102 Message: "With icon",
@@ -111,10 +111,10 @@ func TestOSC99Backend_Send_WithIcon(t *testing.T) {
111 require.NotContains(t, s, "\x1b]9;")
111 require.NotContains(t, s, "\x1b]9;")
112}
112}
113
113
114−func TestOSC777Backend_Send(t *testing.T) {
114+func TestOSCBackend_Send_OSC777(t *testing.T) {
115 t.Parallel()
115 t.Parallel()
116
116
117− backend := notification.NewOSC777Backend()
117+ backend := notification.NewOSCBackend(nil, false)
118 s := extractRawString(t, backend.Send(notification.Notification{
118 s := extractRawString(t, backend.Send(notification.Notification{
119 Title: "Test",
119 Title: "Test",
120 Message: "With body",
120 Message: "With body",
@@ -196,3 +196,18 @@ func TestOSC99QuerySequence(t *testing.T) {
196 require.Contains(t, seq, "p=?")
196 require.Contains(t, seq, "p=?")
197 require.Contains(t, seq, "\x07")
197 require.Contains(t, seq, "\x07")
198}
198}
199+
200+func TestBellBackend_Send(t *testing.T) {
201+ t.Parallel()
202+
203+ backend := notification.NewBellBackend()
204+ s := extractRawString(t, backend.Send(notification.Notification{
205+ Title: "Test",
206+ Message: "Ignored by bell",
207+ }))
208+
209+ // Bell backend only sends the bell character.
210+ require.Equal(t, "\x07", s)
211+ require.NotContains(t, s, "Test")
212+ require.NotContains(t, s, "Ignored")
213+}
internal/ui/notification/osc.go+24−20
@@ -78,23 +78,37 @@ func OSC99QuerySequence() string {
78 return ansi.DesktopNotification("", "i="+osc99QueryID, "p=?")
78 return ansi.DesktopNotification("", "i="+osc99QueryID, "p=?")
79}
79}
80
80
81−// OSC99Backend sends desktop notifications using OSC 99.
81+// OSCBackend sends desktop notifications using OSC escape sequences. It
82+// automatically selects the best available protocol: OSC 99 (modern standard)
83+// if supported, falling back to OSC 777 (urxvt extension) otherwise.
82−type OSC99Backend struct {
84+type OSCBackend struct {
83− icon []byte
85+ icon []byte
86+ supports99 bool
84}
87}
85
88
86−// NewOSC99Backend creates a new OSC 99 notification backend.
89+// NewOSCBackend creates a new OSC notification backend with automatic protocol
87−func NewOSC99Backend(icon any) *OSC99Backend {
90+// detection. If supports99 is true, it uses OSC 99; otherwise it falls back to
88− b := &OSC99Backend{}
91+// OSC 777.
92+func NewOSCBackend(icon any, supports99 bool) *OSCBackend {
93+ b := &OSCBackend{
94+ supports99: supports99,
95+ }
89 if data, ok := icon.([]byte); ok && len(data) > 0 {
96 if data, ok := icon.([]byte); ok && len(data) > 0 {
90 b.icon = data
97 b.icon = data
91 }
98 }
92 return b
99 return b
93}
100}
94
101
95−// Send returns a [tea.Raw] command that writes OSC 99 escape sequences to the
102+// Send returns a [tea.Cmd] that writes OSC escape sequences to the terminal.
96−// terminal.
103+// Uses OSC 99 if supported, otherwise OSC 777.
97−func (b *OSC99Backend) Send(n Notification) tea.Cmd {
104+func (b *OSCBackend) Send(n Notification) tea.Cmd {
105+ if b.supports99 {
106+ return b.sendOSC99(n)
107+ }
108+ return b.sendOSC777(n)
109+}
110+
111+func (b *OSCBackend) sendOSC99(n Notification) tea.Cmd {
98 slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
112 slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
99
113
100 var sb strings.Builder
114 var sb strings.Builder
@@ -119,17 +133,7 @@ func (b *OSC99Backend) Send(n Notification) tea.Cmd {
119 return tea.Raw(sb.String())
133 return tea.Raw(sb.String())
120}
134}
121
135
122−// OSC777Backend sends desktop notifications using OSC 777.
123−type OSC777Backend struct{}
124−
125−// NewOSC777Backend creates a new OSC 777 notification backend.
126−func NewOSC777Backend() *OSC777Backend {
127− return &OSC777Backend{}
128−}
129−
130−// Send returns a [tea.Raw] command that writes an OSC 777 escape sequence to
131−// the terminal.
132−func (b *OSC777Backend) Send(n Notification) tea.Cmd {
136+func (b *OSCBackend) sendOSC777(n Notification) tea.Cmd {
133 slog.Debug("Sending OSC 777 notification", "title", n.Title, "message", n.Message)
137 slog.Debug("Sending OSC 777 notification", "title", n.Title, "message", n.Message)
134
138
135 return tea.Raw(ansi.URxvtExt("notify", n.Title, n.Message))
139 return tea.Raw(ansi.URxvtExt("notify", n.Title, n.Message))