terminal: expose desktop notification effect

This commit is contained in:
Jack Pearkes
2026-07-27 10:07:07 -04:00
parent 5a35415a5d
commit c3655ba258
4 changed files with 233 additions and 1 deletions

View File

@@ -94,6 +94,7 @@ extern "C" {
* | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) |
* | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)|
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 |
* | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 |
*
* ### Defining a write_pty callback
* @snippet c-vt-effects/src/main.c effects-write-pty
@@ -447,6 +448,42 @@ typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)(
void* userdata,
const GhosttyClipboardWrite* write);
/**
* A request to show a desktop notification.
*
* This is a sized struct. The callback must only access fields present in the
* size reported by `size`. Both strings are borrowed and valid only for the
* duration of the callback.
*
* @ingroup terminal
*/
typedef struct {
/** Size of this struct in bytes. */
size_t size;
/** Notification title, or an empty string when the protocol omits it. */
GhosttyString title;
/** Notification body. */
GhosttyString body;
} GhosttyTerminalDesktopNotification;
/**
* Callback function type for desktop notifications.
*
* Called synchronously when the terminal receives OSC 9 or OSC 777.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param notification Borrowed desktop notification request
*
* @ingroup terminal
*/
typedef void (*GhosttyTerminalDesktopNotificationFn)(
GhosttyTerminal terminal,
void* userdata,
const GhosttyTerminalDesktopNotification* notification);
/**
* Callback function type for color scheme queries (CSI ? 996 n).
*
@@ -914,6 +951,15 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: size_t*
*/
GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_LINES = 28,
/**
* Callback invoked when the running program requests a desktop
* notification via OSC 9 or OSC 777. Set to NULL to ignore desktop
* notification requests.
*
* Input type: GhosttyTerminalDesktopNotificationFn
*/
GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION = 29,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;

View File

@@ -5,6 +5,7 @@ const build_options = @import("terminal_options");
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
pub const ZigTerminal = @import("../Terminal.zig");
const Action = @import("../stream.zig").Action;
const Stream = @import("../stream_terminal.zig").Stream;
const Screen = @import("../Screen.zig");
const ScreenSet = @import("../ScreenSet.zig");
@@ -81,6 +82,15 @@ pub const ClipboardWrite = extern struct {
contents_len: usize,
};
/// A request to show a desktop notification.
///
/// C: GhosttyTerminalDesktopNotification
pub const DesktopNotification = extern struct {
size: usize,
title: lib.String,
body: lib.String,
};
/// C callback state for terminal effects. Trampolines are always
/// installed on the stream handler; they check these fields and
/// no-op when the corresponding callback is null.
@@ -89,6 +99,7 @@ const Effects = struct {
write_pty: ?WritePtyFn = null,
bell: ?BellFn = null,
color_scheme: ?ColorSchemeFn = null,
desktop_notification: ?DesktopNotificationFn = null,
device_attributes_cb: ?DeviceAttributesFn = null,
enquiry: ?EnquiryFn = null,
xtversion: ?XtversionFn = null,
@@ -130,6 +141,10 @@ const Effects = struct {
/// and its contents are borrowed and only valid for the callback duration.
pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) clipboard.WriteResult;
/// C function pointer type for the desktop_notification callback. The
/// request and its strings are borrowed for the callback duration.
pub const DesktopNotificationFn = *const fn (Terminal, ?*anyopaque, *const DesktopNotification) callconv(lib.calling_conv) void;
/// C function pointer type for the title_changed callback.
pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
@@ -219,6 +234,26 @@ const Effects = struct {
return func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
}
fn desktopNotificationTrampoline(
handler: *Handler,
notification: Action.ShowDesktopNotification,
) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.desktop_notification orelse return;
const request: DesktopNotification = .{
.size = @sizeOf(DesktopNotification),
.title = .{
.ptr = notification.title.ptr,
.len = notification.title.len,
},
.body = .{
.ptr = notification.body.ptr,
.len = notification.body.len,
},
};
func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
}
fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.color_scheme orelse return null;
@@ -376,6 +411,7 @@ fn new_(
.write_pty = &Effects.writePtyTrampoline,
.bell = &Effects.bellTrampoline,
.color_scheme = &Effects.colorSchemeTrampoline,
.desktop_notification = &Effects.desktopNotificationTrampoline,
.device_attributes = &Effects.deviceAttributesTrampoline,
.enquiry = &Effects.enquiryTrampoline,
.xtversion = &Effects.xtversionTrampoline,
@@ -457,6 +493,7 @@ pub const Option = enum(c_int) {
clipboard_write = 26,
scrollback_max_bytes = 27,
scrollback_max_lines = 28,
desktop_notification = 29,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -465,6 +502,7 @@ pub const Option = enum(c_int) {
.write_pty => ?Effects.WritePtyFn,
.bell => ?Effects.BellFn,
.color_scheme => ?Effects.ColorSchemeFn,
.desktop_notification => ?Effects.DesktopNotificationFn,
.device_attributes => ?Effects.DeviceAttributesFn,
.enquiry => ?Effects.EnquiryFn,
.xtversion => ?Effects.XtversionFn,
@@ -526,6 +564,7 @@ fn setTyped(
.write_pty => wrapper.effects.write_pty = value,
.bell => wrapper.effects.bell = value,
.color_scheme => wrapper.effects.color_scheme = value,
.desktop_notification => wrapper.effects.desktop_notification = value,
.device_attributes => wrapper.effects.device_attributes_cb = value,
.enquiry => wrapper.effects.enquiry = value,
.xtversion => wrapper.effects.xtversion = value,
@@ -2843,6 +2882,81 @@ test "title_changed without callback is silent" {
vt_write(t, "\x1B]2;Hello\x1B\\", 10);
}
test "set desktop_notification callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
const S = struct {
var count: usize = 0;
var last_userdata: ?*anyopaque = null;
var last_size: usize = 0;
var title: [64]u8 = undefined;
var title_len: usize = 0;
var body: [64]u8 = undefined;
var body_len: usize = 0;
fn desktopNotification(
_: Terminal,
ud: ?*anyopaque,
notification: *const DesktopNotification,
) callconv(lib.calling_conv) void {
count += 1;
last_userdata = ud;
last_size = notification.size;
title_len = notification.title.len;
body_len = notification.body.len;
@memcpy(title[0..title_len], notification.title.ptr[0..title_len]);
@memcpy(body[0..body_len], notification.body.ptr[0..body_len]);
}
};
S.count = 0;
S.last_userdata = null;
S.last_size = 0;
S.title_len = 0;
S.body_len = 0;
var sentinel: u8 = 99;
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
try testing.expectEqual(Result.success, set(
t,
.desktop_notification,
@ptrCast(&S.desktopNotification),
));
// Split OSC 777 across writes to exercise the persistent VT parser.
const seq_a = "\x1B]777;notify;Codex;";
const seq_b = "Needs attention\x1B\\";
vt_write(t, seq_a, seq_a.len);
try testing.expectEqual(@as(usize, 0), S.count);
vt_write(t, seq_b, seq_b.len);
try testing.expectEqual(@as(usize, 1), S.count);
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
try testing.expectEqual(@sizeOf(DesktopNotification), S.last_size);
try testing.expectEqualStrings("Codex", S.title[0..S.title_len]);
try testing.expectEqualStrings("Needs attention", S.body[0..S.body_len]);
// OSC 9 has no title and preserves its body.
const seq_c = "\x1B]9;Build complete\x07";
vt_write(t, seq_c, seq_c.len);
try testing.expectEqual(@as(usize, 2), S.count);
try testing.expectEqualStrings("", S.title[0..S.title_len]);
try testing.expectEqualStrings("Build complete", S.body[0..S.body_len]);
// Removing the callback takes effect immediately.
try testing.expectEqual(Result.success, set(t, .desktop_notification, null));
vt_write(t, seq_c, seq_c.len);
try testing.expectEqual(@as(usize, 2), S.count);
}
test "set pwd_changed callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(

View File

@@ -68,6 +68,7 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: {
.{ "GhosttySurfacePosition", StructInfo.init(SurfacePosition) },
.{ "GhosttyStyle", StructInfo.init(style_c.Style) },
.{ "GhosttyStyleColor", StructInfo.init(style_c.Color) },
.{ "GhosttyTerminalDesktopNotification", StructInfo.init(terminal.DesktopNotification) },
.{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) },
.{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) },
});

View File

@@ -74,6 +74,11 @@ pub const Handler = struct {
/// Called when the bell is rung (BEL).
bell: ?*const fn (*Handler) void,
/// Called when the running program requests a desktop notification
/// via OSC 9 or OSC 777. The title and body are borrowed and only
/// valid for the duration of the callback.
desktop_notification: ?*const fn (*Handler, Action.ShowDesktopNotification) void,
/// Called in response to a color scheme DSR query (CSI ? 996 n).
/// Returns the current color scheme. Return null to silently
/// ignore the query.
@@ -131,6 +136,7 @@ pub const Handler = struct {
.bell = null,
.clipboard_write = null,
.color_scheme = null,
.desktop_notification = null,
.device_attributes = null,
.enquiry = null,
.size = null,
@@ -314,6 +320,7 @@ pub const Handler = struct {
// Effect-based handlers
.bell => self.bell(),
.show_desktop_notification => self.desktopNotification(value),
.device_attributes => self.reportDeviceAttributes(value),
.device_status => self.deviceStatus(value.request),
.enquiry => self.reportEnquiry(),
@@ -337,7 +344,6 @@ pub const Handler = struct {
.dcs_unhook => try self.dcsUnhook(),
// Have no terminal-modifying effect
.show_desktop_notification,
.progress_report,
.title_push,
.title_pop,
@@ -396,6 +402,14 @@ pub const Handler = struct {
func(self);
}
fn desktopNotification(
self: *Handler,
notification: Action.ShowDesktopNotification,
) void {
const func = self.effects.desktop_notification orelse return;
func(self, notification);
}
fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void {
const func = self.effects.clipboard_write orelse return;
@@ -2035,6 +2049,63 @@ test "bell effect callback" {
}
}
test "desktop_notification effect callback" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
// A null callback (the default readonly effects) silently ignores
// notifications and leaves the terminal usable.
{
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
s.nextSlice("\x1B]9;Ignored\x1B\\AfterNotification");
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("AfterNotification", str);
}
t.fullReset();
const S = struct {
var count: usize = 0;
var last_title: []const u8 = "";
var last_body: []const u8 = "";
fn desktopNotification(
_: *Handler,
notification: Action.ShowDesktopNotification,
) void {
count += 1;
last_title = notification.title;
last_body = notification.body;
}
};
S.count = 0;
S.last_title = "";
S.last_body = "";
var handler: Handler = .init(&t);
handler.effects.desktop_notification = &S.desktopNotification;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// OSC 9 is split across writes and carries only a body.
s.nextSlice("\x1B]9;Build ");
try testing.expectEqual(@as(usize, 0), S.count);
s.nextSlice("complete\x1B\\");
try testing.expectEqual(@as(usize, 1), S.count);
try testing.expectEqualStrings("", S.last_title);
try testing.expectEqualStrings("Build complete", S.last_body);
// OSC 777 preserves its separate title and body fields.
s.nextSlice("\x1B]777;notify;Codex;Needs attention\x07");
try testing.expectEqual(@as(usize, 2), S.count);
try testing.expectEqualStrings("Codex", S.last_title);
try testing.expectEqualStrings("Needs attention", S.last_body);
}
test "clipboard_write effect callback" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);