on main help sign in
feat: support notifications for ssh terminal
6da5c95nghiant
internal/ui/common/capabilities.go+10−1
@@ -9,6 +9,8 @@ import (
9 uv "github.com/charmbracelet/ultraviolet"
9 uv "github.com/charmbracelet/ultraviolet"
10 "github.com/charmbracelet/x/ansi"
10 "github.com/charmbracelet/x/ansi"
11 xstrings "github.com/charmbracelet/x/exp/strings"
11 xstrings "github.com/charmbracelet/x/exp/strings"
12+
13+ "github.com/charmbracelet/crush/internal/ui/notification"
12)
14)
13
15
14// Capabilities define different terminal capabilities supported.
16// Capabilities define different terminal capabilities supported.
@@ -35,6 +37,8 @@ type Capabilities struct {
35 TerminalVersion string
37 TerminalVersion string
36 // ReportFocusEvents indicates whether the terminal supports focus events.
38 // ReportFocusEvents indicates whether the terminal supports focus events.
37 ReportFocusEvents bool
39 ReportFocusEvents bool
40+ // OSC99Notifications indicates whether the terminal supports OSC 99 notifications.
41+ OSC99Notifications bool
38}
42}
39
43
40// Update updates the capabilities based on the given message.
44// Update updates the capabilities based on the given message.
@@ -63,6 +67,10 @@ func (c *Capabilities) Update(msg any) {
63 case ansi.ModeFocusEvent:
67 case ansi.ModeFocusEvent:
64 c.ReportFocusEvents = modeSupported(m.Value)
68 c.ReportFocusEvents = modeSupported(m.Value)
65 }
69 }
70+ case uv.UnknownOscEvent:
71+ if notification.DetectOSC99Support(string(m)) {
72+ c.OSC99Notifications = true
73+ }
66 }
74 }
67}
75}
68
76
@@ -72,12 +80,13 @@ func QueryCmd(env uv.Environ) tea.Cmd {
72 var sb strings.Builder
80 var sb strings.Builder
73 sb.WriteString(ansi.RequestPrimaryDeviceAttributes)
81 sb.WriteString(ansi.RequestPrimaryDeviceAttributes)
74 sb.WriteString(ansi.QueryModifyOtherKeys)
82 sb.WriteString(ansi.QueryModifyOtherKeys)
83+ sb.WriteString(ansi.RequestModeFocusEvent)
84+ sb.WriteString(notification.OSC99QuerySequence())
75
85
76 // Queries that should only be sent to "smart" normal terminals.
86 // Queries that should only be sent to "smart" normal terminals.
77 shouldQueryFor := shouldQueryCapabilities(env)
87 shouldQueryFor := shouldQueryCapabilities(env)
78 if shouldQueryFor {
88 if shouldQueryFor {
79 sb.WriteString(ansi.RequestNameVersion)
89 sb.WriteString(ansi.RequestNameVersion)
80− sb.WriteString(ansi.RequestModeFocusEvent)
81 sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
90 sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
82 kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
91 kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
83 if _, isTmux := env.LookupEnv("TMUX"); isTmux {
92 if _, isTmux := env.LookupEnv("TMUX"); isTmux {
internal/ui/model/ui.go+30−8
@@ -432,13 +432,35 @@ func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
432 return nil
432 return nil
433 }
433 }
434
434
435− backend := m.notifyBackend
435+ return m.notifyBackend.Send(n)
436− return func() tea.Msg {
436+}
437− if err := backend.Send(n); err != nil {
437+
438− slog.Error("Failed to send notification", "error", err)
438+// selectNotificationBackend chooses the appropriate notification backend based
439+// on terminal capabilities and environment. This is a pure function that should
440+// be called once during initialization or when capabilities change.
441+func selectNotificationBackend(caps common.Capabilities) notification.Backend {
442+ _, isSSH := caps.Env.LookupEnv("SSH_TTY")
443+
444+ // SSH sessions use terminal-based notifications (OSC 99 or 777).
445+ if isSSH {
446+ if caps.OSC99Notifications {
447+ return notification.NewOSC99Backend(notification.Icon)
439 }
448 }
440− return nil
449+ return notification.NewOSC777Backend()
441 }
450 }
451+
452+ // Local sessions use native OS notifications if focus events are supported.
453+ // Without focus events, we can't suppress notifications when focused, so
454+ // we disable them entirely to avoid spamming the user.
455+ if caps.ReportFocusEvents {
456+ return notification.NewNativeBackend(notification.Icon)
457+ }
458+
459+ return notification.NoopBackend{}
460+}
461+
462+func (m *UI) updateNotificationBackend() {
463+ m.notifyBackend = selectNotificationBackend(m.caps)
442}
464}
443
465
444// shouldSendNotification returns true if notifications should be sent based on
466// 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) {
512 }
534 }
513 cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
535 cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
514 case tea.ModeReportMsg:
536 case tea.ModeReportMsg:
515− if m.caps.ReportFocusEvents {
537+ m.updateNotificationBackend()
516− m.notifyBackend = notification.NewNativeBackend(notification.Icon)
538+ case uv.UnknownOscEvent:
517− }
539+ m.updateNotificationBackend()
518 case tea.FocusMsg:
540 case tea.FocusMsg:
519 m.notifyWindowFocused = true
541 m.notifyWindowFocused = true
520 case tea.BlurMsg:
542 case tea.BlurMsg:
internal/ui/notification/native.go+13−10
@@ -3,6 +3,7 @@ package notification
3import (
3import (
4 "log/slog"
4 "log/slog"
5
5
6+ tea "charm.land/bubbletea/v2"
6 "github.com/gen2brain/beeep"
7 "github.com/gen2brain/beeep"
7)
8)
8
9
@@ -24,18 +25,20 @@ func NewNativeBackend(icon any) *NativeBackend {
24 }
25 }
25}
26}
26
27
27−// Send sends a desktop notification using the native OS notification system.
28+// Send returns a command that sends a desktop notification using the native
29+// OS notification system.
28−func (b *NativeBackend) Send(n Notification) error {
30+func (b *NativeBackend) Send(n Notification) tea.Cmd {
31+ return func() tea.Msg {
29− slog.Debug("Sending native notification", "title", n.Title, "message", n.Message)
32+ slog.Debug("Sending native notification", "title", n.Title, "message", n.Message)
30
33
31− err := b.notifyFunc(n.Title, n.Message, b.icon)
34+ if err := b.notifyFunc(n.Title, n.Message, b.icon); err != nil {
32− if err != nil {
33− slog.Error("Failed to send notification", "error", err)
35+ slog.Error("Failed to send notification", "error", err)
34− } else {
36+ } else {
35− slog.Debug("Notification sent successfully")
37+ slog.Debug("Notification sent successfully")
36− }
38+ }
37
39
38− return err
40+ return nil
41+ }
39}
42}
40
43
41// SetNotifyFunc allows replacing the notification function for testing.
44// SetNotifyFunc allows replacing the notification function for testing.
internal/ui/notification/noop.go+3−1
@@ -1,10 +1,12 @@
1package notification
1package notification
2
2
3+import tea "charm.land/bubbletea/v2"
4+
3// NoopBackend is a no-op notification backend that does nothing.
5// NoopBackend is a no-op notification backend that does nothing.
4// This is the default backend used when notifications are not supported.
6// This is the default backend used when notifications are not supported.
5type NoopBackend struct{}
7type NoopBackend struct{}
6
8
7// Send does nothing and returns nil.
9// Send does nothing and returns nil.
8−func (NoopBackend) Send(_ Notification) error {
10+func (NoopBackend) Send(_ Notification) tea.Cmd {
9 return nil
11 return nil
10}
12}
internal/ui/notification/notification.go+21−3
@@ -1,6 +1,22 @@
1// Package notification provides desktop notification support for the UI.
1// Package notification provides desktop notification support for the UI.
2+//
3+// This package supports multiple notification backends:
4+// - NativeBackend: Uses the native OS notification system (macOS, Windows, Linux)
5+// - OSC99Backend: Uses the OSC 99 Desktop Notification protocol, supported by
6+// modern terminals like kitty, wezterm, and ghostty. Supports rich notifications
7+// with title, body, icons, and actions.
8+// - OSC777Backend: Uses the OSC 777 urxvt notification extension, widely supported
9+// but less capable (title and body only). Used as a fallback for SSH sessions.
10+// - NoopBackend: A no-op backend that silently discards notifications.
11+//
12+// Backend selection is based on terminal capabilities and environment:
13+// - SSH sessions prefer OSC 99 if available, falling back to OSC 777
14+// - Local sessions use native OS notifications
15+// - If focus events are not supported, notifications are disabled (NoopBackend)
2package notification
16package notification
3
17
18+import tea "charm.land/bubbletea/v2"
19+
4// Notification represents a desktop notification request.
20// Notification represents a desktop notification request.
5type Notification struct {
21type Notification struct {
6 Title string
22 Title string
@@ -8,8 +24,10 @@ type Notification struct {
8}
24}
9
25
10// Backend defines the interface for sending desktop notifications.
26// Backend defines the interface for sending desktop notifications.
11−// Implementations are pure transport - policy decisions (config, focus state)
27+// Implementations return a tea.Cmd that performs the notification, allowing
28+// each backend to choose between synchronous (native OS) and asynchronous
29+// (terminal escape sequences) delivery. Policy decisions (config checks,
12−// are handled by the caller.
30+// focus state) are handled by the caller.
13type Backend interface {
31type Backend interface {
14− Send(n Notification) error
32+ Send(n Notification) tea.Cmd
15}
33}
internal/ui/notification/notification_test.go+159−4
@@ -1,8 +1,11 @@
1package notification_test
1package notification_test
2
2
3import (
3import (
4+ "encoding/base64"
5+ "fmt"
4 "testing"
6 "testing"
5
7
8+ tea "charm.land/bubbletea/v2"
6 "github.com/charmbracelet/crush/internal/ui/notification"
9 "github.com/charmbracelet/crush/internal/ui/notification"
7 "github.com/stretchr/testify/require"
10 "github.com/stretchr/testify/require"
8)
11)
@@ -11,11 +14,11 @@ func TestNoopBackend_Send(t *testing.T) {
11 t.Parallel()
14 t.Parallel()
12
15
13 backend := notification.NoopBackend{}
16 backend := notification.NoopBackend{}
14− err := backend.Send(notification.Notification{
17+ cmd := backend.Send(notification.Notification{
15 Title: "Test Title",
18 Title: "Test Title",
16 Message: "Test Message",
19 Message: "Test Message",
17 })
20 })
18− require.NoError(t, err)
21+ require.Nil(t, cmd)
19}
22}
20
23
21func TestNativeBackend_Send(t *testing.T) {
24func TestNativeBackend_Send(t *testing.T) {
@@ -32,12 +35,164 @@ func TestNativeBackend_Send(t *testing.T) {
32 return nil
35 return nil
33 })
36 })
34
37
35− err := backend.Send(notification.Notification{
38+ cmd := backend.Send(notification.Notification{
36 Title: "Hello",
39 Title: "Hello",
37 Message: "World",
40 Message: "World",
38 })
41 })
39− require.NoError(t, err)
42+ require.NotNil(t, cmd)
43+ msg := cmd()
44+ require.Nil(t, msg)
40 require.Equal(t, "Hello", capturedTitle)
45 require.Equal(t, "Hello", capturedTitle)
41 require.Equal(t, "World", capturedMessage)
46 require.Equal(t, "World", capturedMessage)
42 require.Nil(t, capturedIcon)
47 require.Nil(t, capturedIcon)
43}
48}
49+
50+func extractRawString(t *testing.T, cmd tea.Cmd) string {
51+ t.Helper()
52+ require.NotNil(t, cmd)
53+
54+ msg := cmd()
55+ raw, ok := msg.(tea.RawMsg)
56+ require.True(t, ok)
57+
58+ s, ok := raw.Msg.(string)
59+ require.True(t, ok)
60+ return s
61+}
62+
63+func TestOSC99Backend_Send(t *testing.T) {
64+ t.Parallel()
65+
66+ backend := notification.NewOSC99Backend(nil)
67+ s := extractRawString(t, backend.Send(notification.Notification{
68+ Title: "Crush is waiting...",
69+ Message: "Agent's turn completed",
70+ }))
71+
72+ require.Contains(t, s, "p=title")
73+ require.Contains(t, s, "p=body")
74+ require.Contains(t, s, "Crush is waiting...")
75+ require.Contains(t, s, "Agent's turn completed")
76+ require.NotContains(t, s, "p=icon")
77+ require.NotContains(t, s, "\x1b]777;")
78+ require.NotContains(t, s, "\x1b]9;")
79+}
80+
81+func TestOSC99Backend_Send_TitleOnly(t *testing.T) {
82+ t.Parallel()
83+
84+ backend := notification.NewOSC99Backend(nil)
85+ s := extractRawString(t, backend.Send(notification.Notification{
86+ Title: "Crush is waiting...",
87+ }))
88+
89+ require.Contains(t, s, "p=title")
90+ require.NotContains(t, s, "p=body")
91+ require.NotContains(t, s, "\x1b]777;")
92+ require.NotContains(t, s, "\x1b]9;")
93+}
94+
95+func TestOSC99Backend_Send_WithIcon(t *testing.T) {
96+ t.Parallel()
97+
98+ iconData := []byte("fake-png-data")
99+ backend := notification.NewOSC99Backend(iconData)
100+ s := extractRawString(t, backend.Send(notification.Notification{
101+ Title: "Test",
102+ Message: "With icon",
103+ }))
104+
105+ require.Contains(t, s, "p=icon")
106+ require.Contains(t, s, "e=1")
107+
108+ encoded := base64.StdEncoding.EncodeToString(iconData)
109+ require.Contains(t, s, fmt.Sprintf(";%s\x07", encoded))
110+ require.NotContains(t, s, "\x1b]777;")
111+ require.NotContains(t, s, "\x1b]9;")
112+}
113+
114+func TestOSC777Backend_Send(t *testing.T) {
115+ t.Parallel()
116+
117+ backend := notification.NewOSC777Backend()
118+ s := extractRawString(t, backend.Send(notification.Notification{
119+ Title: "Test",
120+ Message: "With body",
121+ }))
122+
123+ require.Equal(t, "\x1b]777;notify;Test;With body\x07", s)
124+ require.NotContains(t, s, "\x1b]99;")
125+ require.NotContains(t, s, "\x1b]9;")
126+}
127+
128+func TestDetectOSC99Support_ValidResponse(t *testing.T) {
129+ t.Parallel()
130+
131+ // Simulate a valid OSC 99 response with title support.
132+ seq := "\x1b]99;i=crush-osc99-query:p=?;p=title\x07"
133+ require.True(t, notification.DetectOSC99Support(seq))
134+}
135+
136+func TestDetectOSC99Support_MultipleCapabilities(t *testing.T) {
137+ t.Parallel()
138+
139+ // Response indicating support for title, body, and icon.
140+ seq := "\x1b]99;i=crush-osc99-query:p=?;p=title,body,icon\x07"
141+ require.True(t, notification.DetectOSC99Support(seq))
142+}
143+
144+func TestDetectOSC99Support_InvalidCommand(t *testing.T) {
145+ t.Parallel()
146+
147+ // OSC 98 instead of 99.
148+ seq := "\x1b]98;i=crush-osc99-query:p=?;p=title\x07"
149+ require.False(t, notification.DetectOSC99Support(seq))
150+}
151+
152+func TestDetectOSC99Support_WrongQueryID(t *testing.T) {
153+ t.Parallel()
154+
155+ // Correct OSC 99 but wrong query ID.
156+ seq := "\x1b]99;i=some-other-id:p=?;p=title\x07"
157+ require.False(t, notification.DetectOSC99Support(seq))
158+}
159+
160+func TestDetectOSC99Support_NoQueryFlag(t *testing.T) {
161+ t.Parallel()
162+
163+ // Missing p=? query flag.
164+ seq := "\x1b]99;i=crush-osc99-query;p=title\x07"
165+ require.False(t, notification.DetectOSC99Support(seq))
166+}
167+
168+func TestDetectOSC99Support_NoTitleCapability(t *testing.T) {
169+ t.Parallel()
170+
171+ // Response without title capability (only body).
172+ seq := "\x1b]99;i=crush-osc99-query:p=?;p=body\x07"
173+ require.False(t, notification.DetectOSC99Support(seq))
174+}
175+
176+func TestDetectOSC99Support_EmptySequence(t *testing.T) {
177+ t.Parallel()
178+
179+ require.False(t, notification.DetectOSC99Support(""))
180+}
181+
182+func TestDetectOSC99Support_MalformedSequence(t *testing.T) {
183+ t.Parallel()
184+
185+ // Missing semicolon separator.
186+ seq := "\x1b]99;i=crush-osc99-query:p=?p=title\x07"
187+ require.False(t, notification.DetectOSC99Support(seq))
188+}
189+
190+func TestOSC99QuerySequence(t *testing.T) {
191+ t.Parallel()
192+
193+ seq := notification.OSC99QuerySequence()
194+ require.Contains(t, seq, "\x1b]99;")
195+ require.Contains(t, seq, "i=crush-osc99-query")
196+ require.Contains(t, seq, "p=?")
197+ require.Contains(t, seq, "\x07")
198+}
internal/ui/notification/osc.gonew file+136
@@ -0,0 +1,136 @@
1+package notification
2+
3+import (
4+ "encoding/base64"
5+ "fmt"
6+ "log/slog"
7+ "strings"
8+
9+ "github.com/charmbracelet/x/ansi"
10+
11+ tea "charm.land/bubbletea/v2"
12+)
13+
14+const osc99QueryID = "crush-osc99-query"
15+
16+// notifySeq is a counter for generating unique notification IDs.
17+var notifySeq uint64
18+
19+// DetectOSC99Support parses an OSC response sequence and returns true if it
20+// indicates OSC 99 notification support. This function should be called from
21+// the capabilities detection layer to determine terminal support.
22+func DetectOSC99Support(seq string) bool {
23+ var ok bool
24+
25+ p := ansi.NewParser()
26+ p.SetHandler(ansi.Handler{
27+ HandleOsc: func(cmd int, data []byte) {
28+ if cmd != 99 {
29+ return
30+ }
31+
32+ response := strings.TrimPrefix(string(data), "99;")
33+ metadata, payload, found := strings.Cut(response, ";")
34+ if !found {
35+ return
36+ }
37+
38+ var hasID, hasQuery bool
39+ for field := range strings.SplitSeq(metadata, ":") {
40+ hasID = hasID || field == "i="+osc99QueryID
41+ hasQuery = hasQuery || field == "p=?"
42+ }
43+ if !hasID || !hasQuery {
44+ return
45+ }
46+
47+ ok = isOSC99CapacityPayload(payload)
48+ },
49+ })
50+
51+ for i := 0; i < len(seq); i++ {
52+ p.Advance(seq[i])
53+ }
54+
55+ return ok
56+}
57+
58+func isOSC99CapacityPayload(payload string) bool {
59+ for field := range strings.SplitSeq(payload, ":") {
60+ key, value, found := strings.Cut(field, "=")
61+ if !found || key != "p" {
62+ continue
63+ }
64+
65+ for item := range strings.SplitSeq(value, ",") {
66+ if item == "title" {
67+ return true
68+ }
69+ }
70+ }
71+
72+ return false
73+}
74+
75+// OSC99QuerySequence returns the OSC 99 query sequence used to detect
76+// terminal support. This should be sent during capability detection.
77+func OSC99QuerySequence() string {
78+ return ansi.DesktopNotification("", "i="+osc99QueryID, "p=?")
79+}
80+
81+// OSC99Backend sends desktop notifications using OSC 99.
82+type OSC99Backend struct {
83+ icon []byte
84+}
85+
86+// NewOSC99Backend creates a new OSC 99 notification backend.
87+func NewOSC99Backend(icon any) *OSC99Backend {
88+ b := &OSC99Backend{}
89+ if data, ok := icon.([]byte); ok && len(data) > 0 {
90+ b.icon = data
91+ }
92+ return b
93+}
94+
95+// Send returns a [tea.Raw] command that writes OSC 99 escape sequences to the
96+// terminal.
97+func (b *OSC99Backend) Send(n Notification) tea.Cmd {
98+ slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
99+
100+ var sb strings.Builder
101+ notifySeq++
102+ id := fmt.Sprintf("crush-%d", notifySeq)
103+
104+ appName := "Crush"
105+ notificationType := "crush-notification"
106+
107+ sb.WriteString(ansi.DesktopNotification(n.Title, "i="+id, "d=0", "p=title", "a="+appName, "t="+notificationType))
108+ if n.Message != "" {
109+ sb.WriteString(ansi.DesktopNotification(n.Message, "i="+id, "d=0", "p=body", "a="+appName, "t="+notificationType))
110+ }
111+
112+ if len(b.icon) > 0 {
113+ encoded := base64.StdEncoding.EncodeToString(b.icon)
114+ sb.WriteString(ansi.DesktopNotification(encoded, "i="+id, "d=0", "p=icon", "e=1", "a="+appName, "t="+notificationType))
115+ }
116+
117+ sb.WriteString(ansi.DesktopNotification("", "i="+id, "d=1", "a="+appName, "t="+notificationType))
118+
119+ return tea.Raw(sb.String())
120+}
121+
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 {
133+ slog.Debug("Sending OSC 777 notification", "title", n.Title, "message", n.Message)
134+
135+ return tea.Raw(ansi.URxvtExt("notify", n.Title, n.Message))
136+}