Skip to content

Commit 2baac58

Browse files
committed
Add support for OSC 99 desktop notifications
1 parent 80fbe01 commit 2baac58

File tree

8 files changed

+553
-17
lines changed

8 files changed

+553
-17
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ Configure terminal at creation:
152152
- `WithBell(provider)`: Handler for bell events
153153
- `WithTitle(provider)`: Handler for title changes
154154
- `WithClipboard(provider)`: Handler for OSC 52 clipboard
155+
- `WithNotification(provider)`: Handler for OSC 99 desktop notifications (Kitty protocol)
155156
- `WithMiddleware(mw)`: Intercept handler calls
156157

157158
### Providers
@@ -162,6 +163,7 @@ Interfaces for external events (all optional, default to no-ops):
162163
- `ClipboardProvider`: Called on OSC 52 (clipboard read/write)
163164
- `ScrollbackProvider`: Stores lines scrolled off top
164165
- `RecordingProvider`: Captures raw input bytes
166+
- `NotificationProvider`: Called on OSC 99 (desktop notifications, Kitty protocol)
165167

166168
### Dirty tracking
167169

@@ -172,6 +174,40 @@ Cells track modification state:
172174

173175
Useful for incremental rendering (only redraw changed cells).
174176

177+
### Desktop Notifications (OSC 99)
178+
179+
The terminal supports the Kitty desktop notification protocol (OSC 99). Implement `NotificationProvider` to handle notifications:
180+
181+
```go
182+
type MyNotificationHandler struct{}
183+
184+
func (h *MyNotificationHandler) Notify(payload *headlessterm.NotificationPayload) string {
185+
// Handle the notification
186+
fmt.Printf("Notification: %s\n", string(payload.Data))
187+
188+
// For query requests, return capabilities
189+
if payload.PayloadType == "?" {
190+
return "\x1b]99;i=test;p=title:body\x1b\\"
191+
}
192+
return ""
193+
}
194+
195+
term := headlessterm.New(
196+
headlessterm.WithNotification(&MyNotificationHandler{}),
197+
)
198+
```
199+
200+
The `NotificationPayload` contains:
201+
- `ID`: Unique identifier for chunking/tracking
202+
- `PayloadType`: Type of data ("title", "body", "icon", "?", etc.)
203+
- `Data`: Payload content (decoded if base64)
204+
- `Urgency`: 0 (low), 1 (normal), 2 (critical)
205+
- `Sound`: Notification sound ("system", "silent", etc.)
206+
- `Actions`: Click behavior ("focus", "report")
207+
- And more fields for icons, timeouts, app name, etc.
208+
209+
See [Kitty Desktop Notifications](https://sw.kovidgoyal.net/kitty/desktop-notifications/) for protocol details.
210+
175211
## Buy me a coffee
176212

177213
Liked some of my work? Buy me a coffee (or more likely a beer)

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/danielgatis/go-headless-term
33
go 1.25.1
44

55
require (
6-
github.com/danielgatis/go-ansicode v1.0.12
6+
github.com/danielgatis/go-ansicode v1.0.13
77
github.com/unilibs/uniwidth v0.1.0
88
)
99

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
github.com/danielgatis/go-ansicode v1.0.12 h1:x23jSR5nvnhBLirKJC9qz/INcSKZct77jB0PAMug1G8=
2-
github.com/danielgatis/go-ansicode v1.0.12/go.mod h1:bQSArJFM0i5KrUsziqk5yrLryYSK30NNOzOTnTm2dGg=
1+
github.com/danielgatis/go-ansicode v1.0.13 h1:RadT1hkYsO6JphWiC4T1NVRjkVAiRtwI9Q9LM90WwCA=
2+
github.com/danielgatis/go-ansicode v1.0.13/go.mod h1:bQSArJFM0i5KrUsziqk5yrLryYSK30NNOzOTnTm2dGg=
33
github.com/danielgatis/go-iterator v0.0.1 h1:pTptWDVAKzR0EUdtfmFMP3v+hSetqB091V9um1DTO8I=
44
github.com/danielgatis/go-iterator v0.0.1/go.mod h1:+gTbPAMdVIKwmqw7kr/mo5IFIzz2MFiRyYylcRvgbHs=
55
github.com/danielgatis/go-utf8 v1.0.1 h1:0tXC3eI9I+11X3DwF46Auqjcq1KrsQCQsaI8k/ymGhU=

handler.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1595,6 +1595,28 @@ func (t *Terminal) startOfStringReceivedInternal(data []byte) {
15951595
}
15961596
}
15971597

1598+
// DesktopNotification handles OSC 99 desktop notification sequences (Kitty protocol).
1599+
// It delegates to the configured NotificationProvider if present.
1600+
// Responses from the provider (e.g., for query requests) are written back to the terminal.
1601+
func (t *Terminal) DesktopNotification(payload *NotificationPayload) {
1602+
if t.middleware != nil && t.middleware.DesktopNotification != nil {
1603+
t.middleware.DesktopNotification(payload, t.desktopNotificationInternal)
1604+
return
1605+
}
1606+
t.desktopNotificationInternal(payload)
1607+
}
1608+
1609+
func (t *Terminal) desktopNotificationInternal(payload *NotificationPayload) {
1610+
if t.notificationProvider == nil {
1611+
return
1612+
}
1613+
1614+
response := t.notificationProvider.Notify(payload)
1615+
if response != "" {
1616+
t.writeResponseString(response)
1617+
}
1618+
}
1619+
15981620
// SetTerminalCharAttribute applies SGR attributes to the cell template (colors, bold, underline, etc.).
15991621
func (t *Terminal) SetTerminalCharAttribute(attr ansicode.TerminalCharAttribute) {
16001622
if t.middleware != nil && t.middleware.SetTerminalCharAttribute != nil {

middleware.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ type Middleware struct {
212212

213213
// SixelReceived wraps the SixelReceived handler
214214
SixelReceived func(params [][]uint16, data []byte, next func([][]uint16, []byte))
215+
216+
// DesktopNotification wraps the DesktopNotification handler (OSC 99)
217+
DesktopNotification func(payload *NotificationPayload, next func(*NotificationPayload))
215218
}
216219

217220
// Merge copies non-nil middleware functions from other into this, overwriting existing values.
@@ -424,4 +427,7 @@ func (m *Middleware) Merge(other *Middleware) {
424427
if other.SixelReceived != nil {
425428
m.SixelReceived = other.SixelReceived
426429
}
430+
if other.DesktopNotification != nil {
431+
m.DesktopNotification = other.DesktopNotification
432+
}
427433
}

0 commit comments

Comments
 (0)