Detailed changes
@@ -9,6 +9,8 @@ import (
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
xstrings "github.com/charmbracelet/x/exp/strings"
+
+ "github.com/charmbracelet/crush/internal/ui/notification"
)
// Capabilities define different terminal capabilities supported.
@@ -35,6 +37,8 @@ type Capabilities struct {
TerminalVersion string
// ReportFocusEvents indicates whether the terminal supports focus events.
ReportFocusEvents bool
+ // OSC99Notifications indicates whether the terminal supports OSC 99 notifications.
+ OSC99Notifications bool
}
// Update updates the capabilities based on the given message.
@@ -63,6 +67,10 @@ func (c *Capabilities) Update(msg any) {
case ansi.ModeFocusEvent:
c.ReportFocusEvents = modeSupported(m.Value)
}
+ case uv.UnknownOscEvent:
+ if notification.DetectOSC99Support(string(m)) {
+ c.OSC99Notifications = true
+ }
}
}
@@ -72,12 +80,13 @@ func QueryCmd(env uv.Environ) tea.Cmd {
var sb strings.Builder
sb.WriteString(ansi.RequestPrimaryDeviceAttributes)
sb.WriteString(ansi.QueryModifyOtherKeys)
+ sb.WriteString(ansi.RequestModeFocusEvent)
+ sb.WriteString(notification.OSC99QuerySequence())
// Queries that should only be sent to "smart" normal terminals.
shouldQueryFor := shouldQueryCapabilities(env)
if shouldQueryFor {
sb.WriteString(ansi.RequestNameVersion)
- sb.WriteString(ansi.RequestModeFocusEvent)
sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
if _, isTmux := env.LookupEnv("TMUX"); isTmux {
@@ -432,13 +432,35 @@ func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
return nil
}
- backend := m.notifyBackend
- return func() tea.Msg {
- if err := backend.Send(n); err != nil {
- slog.Error("Failed to send notification", "error", err)
+ return m.notifyBackend.Send(n)
+}
+
+// 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 {
+ _, 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 nil
+ return notification.NewOSC777Backend()
}
+
+ // 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 {
+ return notification.NewNativeBackend(notification.Icon)
+ }
+
+ return notification.NoopBackend{}
+}
+
+func (m *UI) updateNotificationBackend() {
+ m.notifyBackend = selectNotificationBackend(m.caps)
}
// shouldSendNotification returns true if notifications should be sent based on
@@ -512,9 +534,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
case tea.ModeReportMsg:
- if m.caps.ReportFocusEvents {
- m.notifyBackend = notification.NewNativeBackend(notification.Icon)
- }
+ m.updateNotificationBackend()
+ case uv.UnknownOscEvent:
+ m.updateNotificationBackend()
case tea.FocusMsg:
m.notifyWindowFocused = true
case tea.BlurMsg:
@@ -3,6 +3,7 @@ package notification
import (
"log/slog"
+ tea "charm.land/bubbletea/v2"
"github.com/gen2brain/beeep"
)
@@ -24,18 +25,20 @@ func NewNativeBackend(icon any) *NativeBackend {
}
}
-// Send sends a desktop notification using the native OS notification system.
-func (b *NativeBackend) Send(n Notification) error {
- slog.Debug("Sending native notification", "title", n.Title, "message", n.Message)
+// Send returns a command that sends a desktop notification using the native
+// OS notification system.
+func (b *NativeBackend) Send(n Notification) tea.Cmd {
+ return func() tea.Msg {
+ slog.Debug("Sending native notification", "title", n.Title, "message", n.Message)
- err := b.notifyFunc(n.Title, n.Message, b.icon)
- if err != nil {
- slog.Error("Failed to send notification", "error", err)
- } else {
- slog.Debug("Notification sent successfully")
- }
+ if err := b.notifyFunc(n.Title, n.Message, b.icon); err != nil {
+ slog.Error("Failed to send notification", "error", err)
+ } else {
+ slog.Debug("Notification sent successfully")
+ }
- return err
+ return nil
+ }
}
// SetNotifyFunc allows replacing the notification function for testing.
@@ -1,10 +1,12 @@
package notification
+import tea "charm.land/bubbletea/v2"
+
// NoopBackend is a no-op notification backend that does nothing.
// This is the default backend used when notifications are not supported.
type NoopBackend struct{}
// Send does nothing and returns nil.
-func (NoopBackend) Send(_ Notification) error {
+func (NoopBackend) Send(_ Notification) tea.Cmd {
return nil
}
@@ -1,6 +1,22 @@
// Package notification provides desktop notification support for the UI.
+//
+// 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.
+//
+// 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)
package notification
+import tea "charm.land/bubbletea/v2"
+
// Notification represents a desktop notification request.
type Notification struct {
Title string
@@ -8,8 +24,10 @@ type Notification struct {
}
// Backend defines the interface for sending desktop notifications.
-// Implementations are pure transport - policy decisions (config, focus state)
-// are handled by the caller.
+// Implementations return a tea.Cmd that performs the notification, allowing
+// each backend to choose between synchronous (native OS) and asynchronous
+// (terminal escape sequences) delivery. Policy decisions (config checks,
+// focus state) are handled by the caller.
type Backend interface {
- Send(n Notification) error
+ Send(n Notification) tea.Cmd
}
@@ -1,8 +1,11 @@
package notification_test
import (
+ "encoding/base64"
+ "fmt"
"testing"
+ tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/crush/internal/ui/notification"
"github.com/stretchr/testify/require"
)
@@ -11,11 +14,11 @@ func TestNoopBackend_Send(t *testing.T) {
t.Parallel()
backend := notification.NoopBackend{}
- err := backend.Send(notification.Notification{
+ cmd := backend.Send(notification.Notification{
Title: "Test Title",
Message: "Test Message",
})
- require.NoError(t, err)
+ require.Nil(t, cmd)
}
func TestNativeBackend_Send(t *testing.T) {
@@ -32,12 +35,164 @@ func TestNativeBackend_Send(t *testing.T) {
return nil
})
- err := backend.Send(notification.Notification{
+ cmd := backend.Send(notification.Notification{
Title: "Hello",
Message: "World",
})
- require.NoError(t, err)
+ require.NotNil(t, cmd)
+ msg := cmd()
+ require.Nil(t, msg)
require.Equal(t, "Hello", capturedTitle)
require.Equal(t, "World", capturedMessage)
require.Nil(t, capturedIcon)
}
+
+func extractRawString(t *testing.T, cmd tea.Cmd) string {
+ t.Helper()
+ require.NotNil(t, cmd)
+
+ msg := cmd()
+ raw, ok := msg.(tea.RawMsg)
+ require.True(t, ok)
+
+ s, ok := raw.Msg.(string)
+ require.True(t, ok)
+ return s
+}
+
+func TestOSC99Backend_Send(t *testing.T) {
+ t.Parallel()
+
+ backend := notification.NewOSC99Backend(nil)
+ s := extractRawString(t, backend.Send(notification.Notification{
+ Title: "Crush is waiting...",
+ Message: "Agent's turn completed",
+ }))
+
+ require.Contains(t, s, "p=title")
+ require.Contains(t, s, "p=body")
+ require.Contains(t, s, "Crush is waiting...")
+ require.Contains(t, s, "Agent's turn completed")
+ require.NotContains(t, s, "p=icon")
+ require.NotContains(t, s, "\x1b]777;")
+ require.NotContains(t, s, "\x1b]9;")
+}
+
+func TestOSC99Backend_Send_TitleOnly(t *testing.T) {
+ t.Parallel()
+
+ backend := notification.NewOSC99Backend(nil)
+ s := extractRawString(t, backend.Send(notification.Notification{
+ Title: "Crush is waiting...",
+ }))
+
+ require.Contains(t, s, "p=title")
+ require.NotContains(t, s, "p=body")
+ require.NotContains(t, s, "\x1b]777;")
+ require.NotContains(t, s, "\x1b]9;")
+}
+
+func TestOSC99Backend_Send_WithIcon(t *testing.T) {
+ t.Parallel()
+
+ iconData := []byte("fake-png-data")
+ backend := notification.NewOSC99Backend(iconData)
+ s := extractRawString(t, backend.Send(notification.Notification{
+ Title: "Test",
+ Message: "With icon",
+ }))
+
+ require.Contains(t, s, "p=icon")
+ require.Contains(t, s, "e=1")
+
+ encoded := base64.StdEncoding.EncodeToString(iconData)
+ require.Contains(t, s, fmt.Sprintf(";%s\x07", encoded))
+ require.NotContains(t, s, "\x1b]777;")
+ require.NotContains(t, s, "\x1b]9;")
+}
+
+func TestOSC777Backend_Send(t *testing.T) {
+ t.Parallel()
+
+ backend := notification.NewOSC777Backend()
+ s := extractRawString(t, backend.Send(notification.Notification{
+ Title: "Test",
+ Message: "With body",
+ }))
+
+ require.Equal(t, "\x1b]777;notify;Test;With body\x07", s)
+ require.NotContains(t, s, "\x1b]99;")
+ require.NotContains(t, s, "\x1b]9;")
+}
+
+func TestDetectOSC99Support_ValidResponse(t *testing.T) {
+ t.Parallel()
+
+ // Simulate a valid OSC 99 response with title support.
+ seq := "\x1b]99;i=crush-osc99-query:p=?;p=title\x07"
+ require.True(t, notification.DetectOSC99Support(seq))
+}
+
+func TestDetectOSC99Support_MultipleCapabilities(t *testing.T) {
+ t.Parallel()
+
+ // Response indicating support for title, body, and icon.
+ seq := "\x1b]99;i=crush-osc99-query:p=?;p=title,body,icon\x07"
+ require.True(t, notification.DetectOSC99Support(seq))
+}
+
+func TestDetectOSC99Support_InvalidCommand(t *testing.T) {
+ t.Parallel()
+
+ // OSC 98 instead of 99.
+ seq := "\x1b]98;i=crush-osc99-query:p=?;p=title\x07"
+ require.False(t, notification.DetectOSC99Support(seq))
+}
+
+func TestDetectOSC99Support_WrongQueryID(t *testing.T) {
+ t.Parallel()
+
+ // Correct OSC 99 but wrong query ID.
+ seq := "\x1b]99;i=some-other-id:p=?;p=title\x07"
+ require.False(t, notification.DetectOSC99Support(seq))
+}
+
+func TestDetectOSC99Support_NoQueryFlag(t *testing.T) {
+ t.Parallel()
+
+ // Missing p=? query flag.
+ seq := "\x1b]99;i=crush-osc99-query;p=title\x07"
+ require.False(t, notification.DetectOSC99Support(seq))
+}
+
+func TestDetectOSC99Support_NoTitleCapability(t *testing.T) {
+ t.Parallel()
+
+ // Response without title capability (only body).
+ seq := "\x1b]99;i=crush-osc99-query:p=?;p=body\x07"
+ require.False(t, notification.DetectOSC99Support(seq))
+}
+
+func TestDetectOSC99Support_EmptySequence(t *testing.T) {
+ t.Parallel()
+
+ require.False(t, notification.DetectOSC99Support(""))
+}
+
+func TestDetectOSC99Support_MalformedSequence(t *testing.T) {
+ t.Parallel()
+
+ // Missing semicolon separator.
+ seq := "\x1b]99;i=crush-osc99-query:p=?p=title\x07"
+ require.False(t, notification.DetectOSC99Support(seq))
+}
+
+func TestOSC99QuerySequence(t *testing.T) {
+ t.Parallel()
+
+ seq := notification.OSC99QuerySequence()
+ require.Contains(t, seq, "\x1b]99;")
+ require.Contains(t, seq, "i=crush-osc99-query")
+ require.Contains(t, seq, "p=?")
+ require.Contains(t, seq, "\x07")
+}
@@ -0,0 +1,136 @@
+package notification
+
+import (
+ "encoding/base64"
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/charmbracelet/x/ansi"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+const osc99QueryID = "crush-osc99-query"
+
+// notifySeq is a counter for generating unique notification IDs.
+var notifySeq uint64
+
+// DetectOSC99Support parses an OSC response sequence and returns true if it
+// indicates OSC 99 notification support. This function should be called from
+// the capabilities detection layer to determine terminal support.
+func DetectOSC99Support(seq string) bool {
+ var ok bool
+
+ p := ansi.NewParser()
+ p.SetHandler(ansi.Handler{
+ HandleOsc: func(cmd int, data []byte) {
+ if cmd != 99 {
+ return
+ }
+
+ response := strings.TrimPrefix(string(data), "99;")
+ metadata, payload, found := strings.Cut(response, ";")
+ if !found {
+ return
+ }
+
+ var hasID, hasQuery bool
+ for field := range strings.SplitSeq(metadata, ":") {
+ hasID = hasID || field == "i="+osc99QueryID
+ hasQuery = hasQuery || field == "p=?"
+ }
+ if !hasID || !hasQuery {
+ return
+ }
+
+ ok = isOSC99CapacityPayload(payload)
+ },
+ })
+
+ for i := 0; i < len(seq); i++ {
+ p.Advance(seq[i])
+ }
+
+ return ok
+}
+
+func isOSC99CapacityPayload(payload string) bool {
+ for field := range strings.SplitSeq(payload, ":") {
+ key, value, found := strings.Cut(field, "=")
+ if !found || key != "p" {
+ continue
+ }
+
+ for item := range strings.SplitSeq(value, ",") {
+ if item == "title" {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+// OSC99QuerySequence returns the OSC 99 query sequence used to detect
+// terminal support. This should be sent during capability detection.
+func OSC99QuerySequence() string {
+ return ansi.DesktopNotification("", "i="+osc99QueryID, "p=?")
+}
+
+// OSC99Backend sends desktop notifications using OSC 99.
+type OSC99Backend struct {
+ icon []byte
+}
+
+// NewOSC99Backend creates a new OSC 99 notification backend.
+func NewOSC99Backend(icon any) *OSC99Backend {
+ b := &OSC99Backend{}
+ 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 {
+ slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
+
+ var sb strings.Builder
+ notifySeq++
+ id := fmt.Sprintf("crush-%d", notifySeq)
+
+ appName := "Crush"
+ notificationType := "crush-notification"
+
+ sb.WriteString(ansi.DesktopNotification(n.Title, "i="+id, "d=0", "p=title", "a="+appName, "t="+notificationType))
+ if n.Message != "" {
+ sb.WriteString(ansi.DesktopNotification(n.Message, "i="+id, "d=0", "p=body", "a="+appName, "t="+notificationType))
+ }
+
+ if len(b.icon) > 0 {
+ encoded := base64.StdEncoding.EncodeToString(b.icon)
+ sb.WriteString(ansi.DesktopNotification(encoded, "i="+id, "d=0", "p=icon", "e=1", "a="+appName, "t="+notificationType))
+ }
+
+ sb.WriteString(ansi.DesktopNotification("", "i="+id, "d=1", "a="+appName, "t="+notificationType))
+
+ 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 {
+ slog.Debug("Sending OSC 777 notification", "title", n.Title, "message", n.Message)
+
+ return tea.Raw(ansi.URxvtExt("notify", n.Title, n.Message))
+}