feat: support notifications for ssh terminal

nghiant created

Change summary

internal/ui/common/capabilities.go            |  11 +
internal/ui/model/ui.go                       |  38 +++-
internal/ui/notification/native.go            |  23 +-
internal/ui/notification/noop.go              |   4 
internal/ui/notification/notification.go      |  24 ++
internal/ui/notification/notification_test.go | 163 ++++++++++++++++++++
internal/ui/notification/osc.go               | 136 +++++++++++++++++
7 files changed, 372 insertions(+), 27 deletions(-)

Detailed changes

internal/ui/common/capabilities.go 🔗

@@ -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 {

internal/ui/model/ui.go 🔗

@@ -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:

internal/ui/notification/native.go 🔗

@@ -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.

internal/ui/notification/noop.go 🔗

@@ -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
 }

internal/ui/notification/notification.go 🔗

@@ -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
 }

internal/ui/notification/notification_test.go 🔗

@@ -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")
+}

internal/ui/notification/osc.go 🔗

@@ -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))
+}