From 6da5c959dd816ca17decb89b4fde91ce9289fd67 Mon Sep 17 00:00:00 2001 From: nghiant Date: Thu, 9 Apr 2026 11:00:15 +0700 Subject: [PATCH] feat: support notifications for ssh terminal --- 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(-) create mode 100644 internal/ui/notification/osc.go diff --git a/internal/ui/common/capabilities.go b/internal/ui/common/capabilities.go index 36d72f3e27f5d837107712849c3d4d7882c94cac..2c45dad9f4af88796edd283a8c9b85f0634cd1c7 100644 --- a/internal/ui/common/capabilities.go +++ b/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 { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 3f4900dbb380ae57a41d6033e8547c3c60d9d740..108501b9199aaa22ad46e787ce39235f06fc047e 100644 --- a/internal/ui/model/ui.go +++ b/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: diff --git a/internal/ui/notification/native.go b/internal/ui/notification/native.go index 4fffa6d2de6798f8c343c3789689844a911b6eb0..9b497e1337ec69a6259bbee20e32a8775680e9c6 100644 --- a/internal/ui/notification/native.go +++ b/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. diff --git a/internal/ui/notification/noop.go b/internal/ui/notification/noop.go index 7e943e38af15ad4e2dcd47c95158bb4abcb6bb56..a30cc5bf40ceee2ff0a1f6bdc79db3013d51130b 100644 --- a/internal/ui/notification/noop.go +++ b/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 } diff --git a/internal/ui/notification/notification.go b/internal/ui/notification/notification.go index f6be12bfe8b84c2cf18b4c5f1ae3720e820e6cd5..d8b17a815d29dc61871e35183d90b9cb694035ed 100644 --- a/internal/ui/notification/notification.go +++ b/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 } diff --git a/internal/ui/notification/notification_test.go b/internal/ui/notification/notification_test.go index 715be608c75328e3bc2b9e820c301a62a17f08a5..1bb3fd400d8ccc167920d1cc73ec4c7023984620 100644 --- a/internal/ui/notification/notification_test.go +++ b/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") +} diff --git a/internal/ui/notification/osc.go b/internal/ui/notification/osc.go new file mode 100644 index 0000000000000000000000000000000000000000..cf083214d681ae0df00107914e5ea949b6d4a776 --- /dev/null +++ b/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)) +}